History

Merge pull request #282 from RhysSullivan/rs/typed-error-edge-model

feat(sdk): typed errors propagate to one edge mapping (StorageError, InternalError, Telemetry)

Rhys Sullivan committed 6 months ago

Commit c28e7102 parents c64a159 4d95b1375 files · +2357 −1017Browse files

Unified · Split

75 files
  1. apps/cloud/src/api/protected-layers.ts +17 −2
  2. apps/cloud/src/api/protected.ts +3 −0
  3. apps/cloud/src/observability.ts +31 −0
  4. apps/cloud/src/services/executor.ts +4 −0
  5. apps/local/src/server/main.ts +13 −1
  6. apps/local/src/server/observability.ts +32 −0
  7. bun.lock +4 −0
  8. notes/error-handling.md +195 −0
  9. notes/promise-sdk-typed-errors.md +78 −0
  10. packages/core/api/package.json +6 −2
  11. packages/core/api/src/executions/api.ts +4 −1
  12. packages/core/api/src/handlers/executions.ts +5 −4
  13. packages/core/api/src/handlers/scope.ts +3 −2
  14. packages/core/api/src/handlers/secrets.ts +21 −22
  15. packages/core/api/src/handlers/sources.ts +16 −17
  16. packages/core/api/src/handlers/tools.ts +7 −6
  17. packages/core/api/src/index.ts +7 −0
  18. packages/core/api/src/observability.test.ts +87 −0
  19. packages/core/api/src/observability.ts +155 −0
  20. packages/core/api/src/scope/api.ts +5 −3
  21. packages/core/api/src/secrets/api.ts +4 −1
  22. packages/core/api/src/services.ts +4 −1
  23. packages/core/api/src/sources/api.ts +16 −5
  24. packages/core/api/src/tools/api.ts +4 −1
  25. packages/core/api/vitest.config.ts +7 −0
  26. packages/core/sdk/package.json +1 −0
  27. packages/core/sdk/src/blob.ts +21 −8
  28. packages/core/sdk/src/error-handling.test.ts +179 −0
  29. packages/core/sdk/src/executor.test.ts +6 −1
  30. packages/core/sdk/src/executor.ts +85 −47
  31. packages/core/sdk/src/index.ts +5 −0
  32. packages/core/sdk/src/plugin.ts +35 −16
  33. packages/core/sdk/src/promise-executor.ts +6 −0
  34. packages/core/sdk/src/secrets.ts +14 −6
  35. packages/core/storage-core/src/adapter.ts +28 −24
  36. packages/core/storage-core/src/errors.ts +42 −0
  37. packages/core/storage-core/src/factory.ts +25 −16
  38. packages/core/storage-core/src/index.ts +3 −0
  39. packages/core/storage-core/src/testing/memory.ts +2 −1
  40. packages/core/storage-core/src/typed.ts +19 −11
  41. packages/core/storage-drizzle/src/adapter.ts +162 −61
  42. packages/core/storage-file/src/blob-store.ts +7 −3
  43. packages/core/storage-postgres/src/blob-store.ts +7 −3
  44. packages/plugins/file-secrets/src/index.ts +57 −22
  45. packages/plugins/google-discovery/src/api/group.ts +38 −26
  46. packages/plugins/google-discovery/src/api/handlers.test.ts +61 −42
  47. packages/plugins/google-discovery/src/api/handlers.ts +48 −55
  48. packages/plugins/google-discovery/src/sdk/binding-store.ts +22 −12
  49. packages/plugins/google-discovery/src/sdk/document.ts +6 −2
  50. packages/plugins/google-discovery/src/sdk/errors.ts +21 −4
  51. packages/plugins/google-discovery/src/sdk/invoke.ts +37 −13
  52. packages/plugins/google-discovery/src/sdk/plugin.ts +34 −16
  53. packages/plugins/graphql/src/api/group.ts +23 −17
  54. packages/plugins/graphql/src/api/handlers.ts +28 −53
  55. packages/plugins/graphql/src/sdk/invoke.ts +5 −5
  56. packages/plugins/graphql/src/sdk/plugin.ts +24 −14
  57. packages/plugins/graphql/src/sdk/store.ts +8 −8
  58. packages/plugins/keychain/src/provider.ts +18 −4
  59. packages/plugins/mcp/src/api/group.ts +39 −48
  60. packages/plugins/mcp/src/api/handlers.test.ts +59 −40
  61. packages/plugins/mcp/src/api/handlers.ts +51 −64
  62. packages/plugins/mcp/src/sdk/binding-store.ts +24 −12
  63. packages/plugins/mcp/src/sdk/errors.ts +16 −3
  64. packages/plugins/mcp/src/sdk/invoke.ts +9 −26
  65. packages/plugins/mcp/src/sdk/plugin.ts +34 −12
  66. packages/plugins/onepassword/src/api/group.ts +19 −14
  67. packages/plugins/onepassword/src/api/handlers.ts +25 −12
  68. packages/plugins/onepassword/src/sdk/errors.ts +9 −4
  69. packages/plugins/onepassword/src/sdk/plugin.ts +108 −103
  70. packages/plugins/openapi/src/api/group.ts +30 −28
  71. packages/plugins/openapi/src/api/handlers.ts +36 −60
  72. packages/plugins/openapi/src/sdk/invoke.ts +29 −8
  73. packages/plugins/openapi/src/sdk/plugin.ts +36 −8
  74. packages/plugins/openapi/src/sdk/store.ts +19 −11
  75. packages/plugins/workos-vault/src/sdk/secret-store.ts +9 −6
apps/cloud/src/api/protected-layers.ts+17−2
@@ -6,7 +6,11 @@ · 5 unmodified lines
66 import { HttpApiBuilder, HttpRouter, HttpServer } from "@effect/platform";
77 import { Layer } from "effect";
88
9−import { CoreExecutorApi } from "@executor/api";
9+import {
10+ CoreExecutorApi,
11+ InternalError,
12+ observabilityMiddleware,
13+} from "@executor/api";
1014 import { CoreHandlers } from "@executor/api/server";
1115 import { OpenApiGroup, OpenApiHandlers } from "@executor/plugin-openapi/api";
1216 import { McpGroup, McpHandlers } from "@executor/plugin-mcp/api";
@@ -18,12 +22,16 @@ import { UserStoreService } from "../auth/context"; · 5 unmodified lines
1822 import { WorkOSAuth } from "../auth/workos";
1923 import { AutumnService } from "../services/autumn";
2024 import { DbService } from "../services/db";
25+import { ErrorCaptureLive } from "../observability";
2126
2227 export const ProtectedCloudApi = CoreExecutorApi.add(OpenApiGroup)
2328 .add(McpGroup)
2429 .add(GraphqlGroup)
30+ .addError(InternalError)
2531 .middleware(OrgAuth);
2632
33+const ObservabilityLive = observabilityMiddleware(ProtectedCloudApi);
34+
2735 const DbLive = DbService.Live;
2836 const UserStoreLive = UserStoreService.Live.pipe(Layer.provide(DbLive));
2937
@@ -47,6 +55,13 @@ export const ProtectedCloudApiHandlers = Layer.mergeAll( · 17 unmodified lines
4755 GraphqlHandlers,
4856 );
4957
58+// `ErrorCaptureLive` is provided above the handler + middleware layers
59+// so the `withCapture` translation path (typed-channel `StorageError →
60+// InternalError(traceId)`) AND the observability middleware's defect
61+// catchall both see the same Sentry-backed implementation.
5062 export const ProtectedCloudApiLive = HttpApiBuilder.api(ProtectedCloudApi).pipe(
51− Layer.provide(Layer.merge(ProtectedCloudApiHandlers, OrgAuthLive)),
63+ Layer.provide(
64+ Layer.mergeAll(ProtectedCloudApiHandlers, OrgAuthLive, ObservabilityLive),
65+ ),
66+ Layer.provide(ErrorCaptureLive),
5267 );
apps/cloud/src/api/protected.ts+3−0
@@ -34,6 +34,9 @@ const createProtectedApp = (organizationId: string, organizationName: string) => · 33 unmodified lines
3434 Effect.gen(function* () {
3535 const { executor, engine } = yield* makeExecutionStack(organizationId, organizationName);
3636
37+ // Handlers wrap their own bodies with `capture(...)` — the edge
38+ // translation lives per-handler, not at service construction. See
39+ // notes/error-handling.md.
3740 const requestServices = Layer.mergeAll(
3841 Layer.succeed(ExecutorService, executor),
3942 Layer.succeed(ExecutionEngineService, engine),
apps/cloud/src/observability.ts+31−0
@@ -0,0 +1,31 @@
1+// ---------------------------------------------------------------------------
2+// Cloud-app implementation of the shared `ErrorCapture` service. This is the
3+// only file in the cloud-app that imports `@sentry/cloudflare` for error
4+// capture — handlers, plugin SDKs, and storage code all stay
5+// Sentry-agnostic and request the `ErrorCapture` tag instead.
6+//
7+// `withObservability` (in @executor/api) wraps every handler effect; when
8+// it sees an unmapped cause it asks `ErrorCapture.captureException` for a
9+// trace id and fails with `InternalError({ traceId })`. The client gets
10+// the opaque id, we get the full cause + stack in Sentry.
11+// ---------------------------------------------------------------------------
12+
13+import * as Sentry from "@sentry/cloudflare";
14+import { Cause, Effect, Layer } from "effect";
15+
16+import { ErrorCapture } from "@executor/api";
17+
18+export const ErrorCaptureLive: Layer.Layer<ErrorCapture> = Layer.succeed(
19+ ErrorCapture,
20+ ErrorCapture.of({
21+ captureException: (cause) =>
22+ Effect.sync(() => {
23+ const squashed = Cause.squash(cause);
24+ const eventId = Sentry.captureException(squashed, (scope) => {
25+ scope.setExtra("cause", Cause.pretty(cause));
26+ return scope;
27+ });
28+ return eventId ?? "";
29+ }),
30+ }),
31+);
apps/cloud/src/services/executor.ts+4−0
@@ -78,5 +78,9 @@ export const createScopedExecutor = ( · 77 unmodified lines
7878 createdAt: new Date(),
7979 });
8080
81+ // The executor surface returns raw `StorageFailure`; translation to
82+ // the opaque `InternalError({ traceId })` happens at the HTTP edge
83+ // via `withCapture` (see `api/protected-layers.ts`). That's
84+ // where `ErrorCaptureLive` (Sentry) gets wired in.
8185 return yield* createExecutor({ scope, adapter, blobs, plugins });
8286 });
apps/local/src/server/main.ts+13−1
@@ -7,7 +7,7 @@ import { · 6 unmodified lines
77 } from "@effect/platform";
88 import { Context, Effect, Layer, ManagedRuntime } from "effect";
99
10−import { addGroup } from "@executor/api";
10+import { addGroup, observabilityMiddleware } from "@executor/api";
1111 import { CoreHandlers, ExecutorService, ExecutionEngineService } from "@executor/api/server";
1212 import { createExecutionEngine } from "@executor/execution";
1313 import { makeQuickJsExecutor } from "@executor/runtime-quickjs";
@@ -34,6 +34,7 @@ import { · 20 unmodified lines
3434 } from "@executor/plugin-graphql/api";
3535 import { getExecutor } from "./executor";
3636 import { createMcpRequestHandler, type McpRequestHandler } from "./mcp";
37+import { ErrorCaptureLive } from "./observability";
3738
3839 // ---------------------------------------------------------------------------
3940 // Local server API — core + all plugin groups
@@ -45,6 +46,13 @@ const LocalApi = addGroup(OpenApiGroup) · 5 unmodified lines
4546 .add(OnePasswordGroup)
4647 .add(GraphqlGroup);
4748
49+// `ErrorCaptureLive` logs causes to the console and returns a short
50+// correlation id. Provided above the handler + middleware layers so
51+// both the `withCapture` typed-channel translation AND the
52+// `observabilityMiddleware` defect catchall see the same
53+// implementation.
54+const LocalObservability = observabilityMiddleware(LocalApi);
55+
4856 const LocalApiBase = HttpApiBuilder.api(LocalApi).pipe(
4957 Layer.provide(CoreHandlers),
5058 Layer.provide(
@@ -56,6 +64,8 @@ const LocalApiBase = HttpApiBuilder.api(LocalApi).pipe( · 5 unmodified lines
5664 GraphqlHandlers,
5765 ),
5866 ),
67+ Layer.provide(LocalObservability),
68+ Layer.provide(ErrorCaptureLive),
5969 );
6070
6171 // ---------------------------------------------------------------------------
@@ -81,6 +91,8 @@ export const createServerHandlers = async (): Promise<ServerHandlers> => { · 19 unmodified lines
8191 const executor = await getExecutor();
8292 const engine = createExecutionEngine({ executor, codeExecutor: makeQuickJsExecutor() });
8393
94+ // Handlers wrap their own bodies with `capture(...)` — the edge
95+ // translation lives per-handler, not at service construction.
8496 const pluginExtensions = Layer.mergeAll(
8597 Layer.succeed(OpenApiExtensionService, executor.openapi),
8698 Layer.succeed(McpExtensionService, executor.mcp),
apps/local/src/server/observability.ts+32−0
@@ -0,0 +1,32 @@
1+// ---------------------------------------------------------------------------
2+// Local-app `ErrorCapture` — console implementation.
3+//
4+// Unlike the cloud app (Sentry-backed), the CLI just prints the squashed
5+// cause + pretty-printed structured cause to stderr and returns a short
6+// correlation id. Operators can grep for the id in their terminal
7+// scrollback when a user reports an opaque 500 traceId.
8+// ---------------------------------------------------------------------------
9+
10+import { Cause, Effect, Layer } from "effect";
11+
12+import { ErrorCapture } from "@executor/api";
13+
14+const nextTraceId = () =>
15+ `local-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
16+
17+export const ErrorCaptureLive: Layer.Layer<ErrorCapture> = Layer.succeed(
18+ ErrorCapture,
19+ ErrorCapture.of({
20+ captureException: (cause) =>
21+ Effect.sync(() => {
22+ const traceId = nextTraceId();
23+ const squashed = Cause.squash(cause);
24+ console.error(
25+ `[executor ${traceId}]`,
26+ squashed instanceof Error ? squashed.stack ?? squashed : squashed,
27+ );
28+ console.error(`[executor ${traceId}] cause:`, Cause.pretty(cause));
29+ return traceId;
30+ }),
31+ }),
32+);
bun.lock+4−0
@@ -229,12 +229,15 @@ · 228 unmodified lines
229229 "@effect/platform": "catalog:",
230230 "@executor/execution": "workspace:*",
231231 "@executor/sdk": "workspace:*",
232+ "@executor/storage-core": "workspace:*",
232233 "effect": "catalog:",
233234 },
234235 "devDependencies": {
236+ "@effect/vitest": "catalog:",
235237 "@types/node": "catalog:",
236238 "bun-types": "catalog:",
237239 "typescript": "catalog:",
240+ "vitest": "catalog:",
238241 },
239242 },
240243 "packages/core/cli": {
@@ -307,6 +310,7 @@ · 66 unmodified lines
307310 "name": "@executor/sdk",
308311 "version": "0.0.1",
309312 "dependencies": {
313+ "@effect/platform": "catalog:",
310314 "@executor/storage-core": "workspace:*",
311315 "effect": "catalog:",
312316 },
notes/error-handling.md+195−0
@@ -0,0 +1,195 @@
1+# Error handling — model + plumbing
2+
3+## Principle
4+
5+Errors are typed values that propagate through the call graph carrying
6+their full causal structure. **Exactly one layer at the edge** consumes
7+them and maps them to a public response. Plugin code never imports
8+Sentry, never calls `captureException`, never wraps handlers with
9+`sanitize*` helpers.
10+
11+Two error categories:
12+
13+- **Surfaceable**: typed Schema errors with a user-actionable message
14+ (`McpOAuthError`, `OpenApiParseError`, …). Carry through to the
15+ response with their own status (4xx) and message body. Not captured
16+ to any external sink — they're normal business outcomes.
17+- **Internal**: truly unexpected (storage failure, third-party returned
18+ garbage, sync throw inside a handler). Captured via `ErrorCapture`
19+ **at the HTTP edge only**, then propagated as the shared
20+ `InternalError({ traceId })` schema — opaque to clients, fully
21+ detailed in Sentry. One shape, one trace id, one place to look up
22+ the cause.
23+
24+## Layering
25+
26+```
27+ storage-core ──▶ sdk ──▶ api (HTTP edge) ──▶ host
28+ StorageError StorageError InternalError ErrorCaptureLive
29+ (raw) (raw) (captured) (Sentry)
30+```
31+
32+The SDK stays storage-typed. The HTTP edge (`@executor/api`) is the
33+**only** layer that translates `StorageError → InternalError` and
34+captures the cause to telemetry. Non-HTTP consumers (CLI, Promise
35+SDK, tests) see raw `StorageError` in the typed channel and can react
36+however they want.
37+
38+## Plumbing
39+
40+### Storage layer (`@executor/storage-core`)
41+
42+Emits two `Data.TaggedError` classes and nothing else observability-related:
43+
44+- `StorageError({ message, cause })` — the catch-all for non-recoverable
45+ backend failures. The `cause` travels as runtime data so the HTTP
46+ edge can capture it, but it's never serialised to the wire.
47+- `UniqueViolationError({ model? })` — typed 4xx-shaped failure plugins
48+ want to react to (e.g. "source already exists").
49+
50+Both are `Data.TaggedError`, not `Schema.TaggedError` — you physically
51+can't `addError(...)` them on an HttpApi group, which enforces "these
52+are internal types, not wire shapes".
53+
54+`DBAdapter` / `CustomAdapter` / `TypedAdapter` declare
55+`Effect<X, StorageFailure>` where `StorageFailure = StorageError |
56+UniqueViolationError`. No `Error`, no telemetry service in R.
57+
58+Storage-core has zero observability awareness — it just emits typed
59+values and lets consumers decide what to do with them.
60+
61+### SDK (`@executor/sdk`)
62+
63+The SDK is entirely observability-free.
64+
65+- `createExecutor` requires no observability service. `R = never`.
66+- `PluginCtx.storage`, `ctx.core.*`, `ctx.secrets.*`, `ctx.transaction`
67+ all surface raw `StorageFailure` in the typed error channel. Plugins
68+ can `Effect.catchTag("UniqueViolationError", …)` and translate to
69+ their own user-facing errors.
70+- Executor public methods (`executor.tools.list()`,
71+ `executor.sources.refresh()`, etc.) also surface raw `StorageFailure`.
72+
73+No `liftStorage`, no `wrapAdapterForPlugin`, no `ErrorCapture` tag
74+inside the SDK. The value proposition: an SDK consumer can write a CLI,
75+a script, a promise-based wrapper, whatever — and the typed channel
76+shows them exactly what can go wrong.
77+
78+### HTTP edge (`@executor/api/observability`)
79+
80+Owns the translation, the opaque wire schema, and the capture service.
81+
82+- `InternalError({ traceId })` — the public opaque 500 schema, with an
83+ `HttpApiSchema.annotations({ status: 500 })` annotation so the
84+ framework encodes it correctly.
85+- `ErrorCapture` — tagged Effect service for recording unexpected
86+ causes. Shape:
87+
88+ ```ts
89+ interface ErrorCaptureShape {
90+ readonly captureException: (cause: Cause<unknown>) => Effect<string>
91+ }
92+ ```
93+
94+ Optional — resolved via `Effect.serviceOption`; missing service =
95+ empty trace ids. Nothing breaks if it's not wired.
96+
97+- `capture(eff)` — the single translator. Catches `StorageError` on
98+ the typed channel, captures the cause via `ErrorCapture`, fails with
99+ `InternalError({ traceId })`. Catches `UniqueViolationError` and
100+ dies (plugins that want to surface it as a typed domain error should
101+ `Effect.catchTag` inside their own method first). Every other typed
102+ failure passes through.
103+
104+ Every handler wraps its generator body with `capture(...)`:
105+
106+ ```ts
107+ .handle("probeEndpoint", ({ payload }) =>
108+ capture(Effect.gen(function* () {
109+ const ext = yield* McpExtensionService;
110+ return yield* ext.probeEndpoint(payload.endpoint);
111+ })),
112+ )
113+ ```
114+
115+ One line at the top of each handler. No service-level proxy, no
116+ `Captured<T>` type gymnastics — the translation is visible right
117+ where it happens, and TypeScript rejects handlers that forget
118+ (because `StorageError` isn't in the group's `.addError` list).
119+
120+- `observabilityMiddleware(Api)` — defect safety net. An
121+ `HttpApiBuilder.middleware` layer that wraps the HttpApp once and
122+ catches any cause that escaped the typed channel (defects,
123+ interrupts, framework bugs) via `ErrorCapture`, returning a typed
124+ `InternalError({ traceId })`. Should rarely fire when the rest of
125+ the pipeline is well-typed.
126+
127+### Plugin SDK
128+
129+Plugin authors write normal Effect code. Their extension method error
130+unions look like:
131+
132+```ts
133+Effect.Effect<X, MyPluginTypedError | StorageError, never>
134+```
135+
136+Where `MyPluginTypedError` is the union of their own
137+`Schema.TaggedError` classes (with `HttpApiSchema.annotations({ status: 4xx })`).
138+`StorageError` is the raw storage tag — it bubbles up, and the HTTP
139+edge translates it.
140+
141+Plugins never provide `ErrorCapture`, never import Sentry, never see
142+`InternalError` in their typed channel.
143+
144+### API groups
145+
146+Each group declares its typed errors once at the group level:
147+
148+```ts
149+class McpGroup extends HttpApiGroup.make("mcp")
150+ .add(endpoint1)
151+ .add(endpoint2)
152+ // …
153+ .addError(InternalError)
154+ .addError(McpOAuthError)
155+ .addError(McpConnectionError)
156+ // …
157+{}
158+```
159+
160+No per-endpoint `addError`. The framework encodes each tagged error by
161+its annotated status.
162+
163+### Hosts
164+
165+- **Cloud Worker** (`apps/cloud/src/observability.ts`) — provides
166+ `ErrorCaptureLive`, a Sentry-backed implementation. Wired at the API
167+ layer in `protected-layers.ts` so it's available to both
168+ `observabilityMiddleware` (defect catchall) AND the per-handler
169+ `capture(...)` translation. Service tags (`ExecutorService`,
170+ `McpExtensionService`, etc.) hold the raw SDK shapes; the cloud app
171+ just does `Layer.succeed(McpExtensionService, executor.mcp)` —
172+ handlers do the translation themselves. (Distinct from
173+ `apps/cloud/src/services/telemetry.ts`, which is the OTEL→Axiom span
174+ bridge — "telemetry" in the tracing sense.)
175+- **CLI** (`apps/local/src/server/main.ts`) — same pattern. Provides a
176+ console-based `ErrorCaptureLive` (`apps/local/src/server/observability.ts`)
177+ that prints the squashed cause + pretty cause to stderr and returns a
178+ short correlation id.
179+- **Tests / Promise SDK / examples** — non-HTTP consumers see raw
180+ `StorageError` / `StorageFailure` in the SDK's typed channel and
181+ can match on it directly.
182+
183+### Anti-patterns
184+
185+- `Effect.orDie` at handler boundaries — silently turns recoverable
186+ failures into 500s with no telemetry (defects bypass typed-channel
187+ encoding).
188+- Per-plugin `*InternalError` types — clients can't tell which plugin
189+ emitted a 500 anyway. Use the shared `InternalError`.
190+- `sanitize*` helpers in handler files that `catchAllCause` + map to a
191+ generic 500 — same swallowing problem in disguise. Prefer wrapping
192+ the handler's `Effect.gen` body with `capture(...)`.
193+- SDK code importing `Sentry.captureException` or referencing
194+ `InternalError` / `ErrorCapture` — translation lives strictly in
195+ `@executor/api`. If the SDK imports it, the layering is wrong.
notes/promise-sdk-typed-errors.md+78−0
@@ -0,0 +1,78 @@
1+# Promise SDK — typed errors revisit
2+
3+## Today
4+
5+`@executor/sdk/promise` wraps every Effect-returning method in
6+`Effect.runPromise(...)`. That works but it's lossy at the Promise
7+boundary:
8+
9+- The promise's *rejection type* is `unknown` (or `any` after
10+ `try/catch`). Consumers have to `instanceof InternalError` /
11+ `err._tag === "..."` to discriminate, even though the underlying
12+ Effect had a fully typed error union.
13+- Defects (uncaught throws, fiber interrupts) become rejections with
14+ whatever value the Effect ran with — sometimes useful, sometimes a
15+ string, depending on the cause.
16+
17+In other words: the Effect-side surface knows your method can fail with
18+`McpOAuthError | InternalError | UniqueViolationError`; the promise
19+consumer sees `Promise<X>` and has to take it on faith.
20+
21+## What we want (revisit)
22+
23+Switch the wrapper to `Effect.runPromiseExit(...)`. The promise always
24+resolves with an `Exit<A, E>`:
25+
26+```ts
27+const exit = await executor.openapi.addSpec({ ... })
28+if (Exit.isSuccess(exit)) {
29+ exit.value // properly typed `A`
30+} else {
31+ // exit.cause is Cause<McpOAuthError | InternalError | ...>
32+ // narrow with Cause.failureOption / Cause.match etc.
33+}
34+```
35+
36+The error union survives the Promise boundary. Defects are visible in
37+the cause. Consumers can write totality-checked `match` on the typed
38+union without any runtime guessing.
39+
40+## Open question for the revisit
41+
42+Do we expose Effect's `Exit` / `Cause` directly to promise consumers,
43+or wrap them in a promise-native `Result<A, E>`?
44+
45+**Expose `Exit`/`Cause`:**
46+- Pros: one fewer abstraction, no translation layer to maintain, cause
47+ preserves parallel/sequential composition.
48+- Cons: consumers depend on `effect`'s API even though they're using a
49+ "Promise" SDK — partly defeats the abstraction.
50+
51+**Wrap in a `Result<A, E>`:**
52+- Pros: `@executor/sdk/promise` consumers don't import `effect` at all,
53+ surface stays small.
54+- Cons: another type to learn / document; loses some Cause structure
55+ (parallel/interrupt) unless we replicate it.
56+
57+Lean toward exposing `Exit`/`Cause`, but worth thinking through what
58+the `runPromiseExit` consumers actually want to do at the call site
59+before deciding.
60+
61+## Why punted
62+
63+Current refactor is about getting Effect-side typed errors right
64+end-to-end (storage → SDK → API). The Promise façade is downstream of
65+that — once the typed unions are stable on the Effect side, the
66+`runPromiseExit` rewrite is a small, well-defined change.
67+
68+Also: it's a breaking change for promise SDK consumers (return type
69+goes from `Promise<A>` to `Promise<Exit<A, E>>`). Better as its own
70+focused PR with a migration note.
71+
72+## Files affected when we do it
73+
74+- `packages/core/sdk/src/promise-executor.ts` (`promisifyDeep`,
75+ `createExecutor`)
76+- `packages/core/sdk/src/promise.ts` if it re-exports surface
77+- Any Promise-SDK consumer in `examples/` or downstream
78+- Doc updates in `notes/error-handling.md`
packages/core/api/package.json+6−2
@@ -9,17 +9,21 @@ · 8 unmodified lines
99 },
1010 "scripts": {
1111 "typecheck": "tsgo --noEmit",
12− "typecheck:slow": "tsc --noEmit"
12+ "typecheck:slow": "tsc --noEmit",
13+ "test": "vitest run"
1314 },
1415 "dependencies": {
1516 "@effect/platform": "catalog:",
1617 "@executor/execution": "workspace:*",
1718 "@executor/sdk": "workspace:*",
19+ "@executor/storage-core": "workspace:*",
1820 "effect": "catalog:"
1921 },
2022 "devDependencies": {
23+ "@effect/vitest": "catalog:",
2124 "@types/node": "catalog:",
2225 "bun-types": "catalog:",
23− "typescript": "catalog:"
26+ "typescript": "catalog:",
27+ "vitest": "catalog:"
2428 }
2529 }
packages/core/api/src/executions/api.ts+4−1
@@ -1,6 +1,8 @@
11 import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "@effect/platform";
22 import { Schema } from "effect";
33
4+import { InternalError } from "../observability";
5+
46 // ---------------------------------------------------------------------------
57 // Schemas
68 // ---------------------------------------------------------------------------
@@ -60,4 +62,5 @@ export class ExecutionsApi extends HttpApiGroup.make("executions") · 53 unmodified lines
6062 .setPayload(ResumeRequest)
6163 .addSuccess(ResumeResponse)
6264 .addError(ExecutionNotFoundError),
63− ) {}
65+ )
66+ .addError(InternalError) {}
packages/core/api/src/handlers/executions.ts+5−4
@@ -4,11 +4,12 @@ import { Effect } from "effect"; · 3 unmodified lines
44 import { ExecutorApi } from "../api";
55 import { formatExecuteResult, formatPausedExecution } from "@executor/execution";
66 import { ExecutionEngineService } from "../services";
7+import { capture } from "@executor/api";
78
89 export const ExecutionsHandlers = HttpApiBuilder.group(ExecutorApi, "executions", (handlers) =>
910 handlers
1011 .handle("execute", ({ payload }) =>
11− Effect.gen(function* () {
12+ capture(Effect.gen(function* () {
1213 const engine = yield* ExecutionEngineService;
1314 const outcome = yield* Effect.promise(() => engine.executeWithPause(payload.code));
1415
@@ -28,10 +29,10 @@ export const ExecutionsHandlers = HttpApiBuilder.group(ExecutorApi, "executions" · 13 unmodified lines
2829 text: formatted.text,
2930 structured: formatted.structured,
3031 };
31− }),
32+ })),
3233 )
3334 .handle("resume", ({ path, payload }) =>
34− Effect.gen(function* () {
35+ capture(Effect.gen(function* () {
3536 const engine = yield* ExecutionEngineService;
3637 const result = yield* Effect.promise(() =>
3738 engine.resume(path.executionId, {
@@ -62,6 +63,6 @@ export const ExecutionsHandlers = HttpApiBuilder.group(ExecutorApi, "executions" · 24 unmodified lines
6263 structured: formatted.structured,
6364 isError: false,
6465 };
65− }),
66+ })),
6667 ),
6768 );
packages/core/api/src/handlers/scope.ts+3−2
@@ -3,16 +3,17 @@ import { Effect } from "effect"; · 2 unmodified lines
33
44 import { ExecutorApi } from "../api";
55 import { ExecutorService } from "../services";
6+import { capture } from "@executor/api";
67
78 export const ScopeHandlers = HttpApiBuilder.group(ExecutorApi, "scope", (handlers) =>
89 handlers.handle("info", () =>
9− Effect.gen(function* () {
10+ capture(Effect.gen(function* () {
1011 const executor = yield* ExecutorService;
1112 return {
1213 id: executor.scope.id,
1314 name: executor.scope.name,
1415 dir: executor.scope.name,
1516 };
16− }),
17+ })),
1718 ),
1819 );
packages/core/api/src/handlers/secrets.ts+21−22
@@ -4,6 +4,7 @@ import { SecretNotFoundError, type SecretRef } from "@executor/sdk"; · 3 unmodified lines
44
55 import { ExecutorApi } from "../api";
66 import { ExecutorService } from "../services";
7+import { capture } from "@executor/api";
78
89 const refToResponse = (ref: SecretRef) => ({
910 id: ref.id,
@@ -16,48 +17,46 @@ const refToResponse = (ref: SecretRef) => ({ · 6 unmodified lines
1617 export const SecretsHandlers = HttpApiBuilder.group(ExecutorApi, "secrets", (handlers) =>
1718 handlers
1819 .handle("list", () =>
19− Effect.gen(function* () {
20+ capture(Effect.gen(function* () {
2021 const executor = yield* ExecutorService;
21− const refs = yield* executor.secrets.list().pipe(Effect.orDie);
22+ const refs = yield* executor.secrets.list();
2223 return refs.map(refToResponse);
23− }),
24+ })),
2425 )
2526 .handle("status", ({ path }) =>
26− Effect.gen(function* () {
27+ capture(Effect.gen(function* () {
2728 const executor = yield* ExecutorService;
28− const status = yield* executor.secrets.status(path.secretId).pipe(Effect.orDie);
29+ const status = yield* executor.secrets.status(path.secretId);
2930 return { secretId: path.secretId, status };
30− }),
31+ })),
3132 )
3233 .handle("set", ({ payload }) =>
33− Effect.gen(function* () {
34+ capture(Effect.gen(function* () {
3435 const executor = yield* ExecutorService;
35− const ref = yield* executor.secrets
36− .set({
37− id: payload.id,
38− name: payload.name,
39− value: payload.value,
40− provider: payload.provider,
41− })
42− .pipe(Effect.orDie);
36+ const ref = yield* executor.secrets.set({
37+ id: payload.id,
38+ name: payload.name,
39+ value: payload.value,
40+ provider: payload.provider,
41+ });
4342 return refToResponse(ref);
44− }),
43+ })),
4544 )
4645 .handle("resolve", ({ path }) =>
47− Effect.gen(function* () {
46+ capture(Effect.gen(function* () {
4847 const executor = yield* ExecutorService;
49− const value = yield* executor.secrets.get(path.secretId).pipe(Effect.orDie);
48+ const value = yield* executor.secrets.get(path.secretId);
5049 if (value === null) {
5150 return yield* Effect.fail(new SecretNotFoundError({ secretId: path.secretId }));
5251 }
5352 return { secretId: path.secretId, value };
54− }),
53+ })),
5554 )
5655 .handle("remove", ({ path }) =>
57− Effect.gen(function* () {
56+ capture(Effect.gen(function* () {
5857 const executor = yield* ExecutorService;
59− yield* executor.secrets.remove(path.secretId).pipe(Effect.orDie);
58+ yield* executor.secrets.remove(path.secretId);
6059 return { removed: true };
61− }),
60+ })),
6261 ),
6362 );
packages/core/api/src/handlers/sources.ts+16−17
@@ -4,13 +4,14 @@ import { ToolId } from "@executor/sdk"; · 3 unmodified lines
44
55 import { ExecutorApi } from "../api";
66 import { ExecutorService } from "../services";
7+import { capture } from "@executor/api";
78
89 export const SourcesHandlers = HttpApiBuilder.group(ExecutorApi, "sources", (handlers) =>
910 handlers
1011 .handle("list", () =>
11− Effect.gen(function* () {
12+ capture(Effect.gen(function* () {
1213 const executor = yield* ExecutorService;
13− const sources = yield* executor.sources.list().pipe(Effect.orDie);
14+ const sources = yield* executor.sources.list();
1415 return sources.map((s) => ({
1516 id: s.id,
1617 name: s.name,
@@ -21,28 +22,26 @@ export const SourcesHandlers = HttpApiBuilder.group(ExecutorApi, "sources", (han · 4 unmodified lines
2122 canRefresh: s.canRefresh,
2223 canEdit: s.canEdit,
2324 }));
24− }),
25+ })),
2526 )
2627 .handle("remove", ({ path }) =>
27− Effect.gen(function* () {
28+ capture(Effect.gen(function* () {
2829 const executor = yield* ExecutorService;
29− yield* executor.sources.remove(path.sourceId).pipe(Effect.orDie);
30+ yield* executor.sources.remove(path.sourceId);
3031 return { removed: true };
31− }),
32+ })),
3233 )
3334 .handle("refresh", ({ path }) =>
34− Effect.gen(function* () {
35+ capture(Effect.gen(function* () {
3536 const executor = yield* ExecutorService;
36− yield* executor.sources.refresh(path.sourceId).pipe(Effect.orDie);
37+ yield* executor.sources.refresh(path.sourceId);
3738 return { refreshed: true };
38− }),
39+ })),
3940 )
4041 .handle("tools", ({ path }) =>
41− Effect.gen(function* () {
42+ capture(Effect.gen(function* () {
4243 const executor = yield* ExecutorService;
43− const tools = yield* executor.tools
44− .list({ sourceId: path.sourceId })
45− .pipe(Effect.orDie);
44+ const tools = yield* executor.tools.list({ sourceId: path.sourceId });
4645 return tools.map((t) => ({
4746 id: ToolId.make(t.id),
4847 pluginId: t.pluginId,
@@ -51,12 +50,12 @@ export const SourcesHandlers = HttpApiBuilder.group(ExecutorApi, "sources", (han · 2 unmodified lines
5150 description: t.description,
5251 mayElicit: t.annotations?.mayElicit,
5352 }));
54− }),
53+ })),
5554 )
5655 .handle("detect", ({ payload }) =>
57− Effect.gen(function* () {
56+ capture(Effect.gen(function* () {
5857 const executor = yield* ExecutorService;
59− const results = yield* executor.sources.detect(payload.url).pipe(Effect.orDie);
58+ const results = yield* executor.sources.detect(payload.url);
6059 return results.map((r) => ({
6160 kind: r.kind,
6261 confidence: r.confidence,
@@ -64,6 +63,6 @@ export const SourcesHandlers = HttpApiBuilder.group(ExecutorApi, "sources", (han · 1 unmodified line
6463 name: r.name,
6564 namespace: r.namespace,
6665 }));
67− }),
66+ })),
6867 ),
6968 );
packages/core/api/src/handlers/tools.ts+7−6
@@ -4,13 +4,14 @@ import { ToolId, ToolNotFoundError } from "@executor/sdk"; · 3 unmodified lines
44
55 import { ExecutorApi } from "../api";
66 import { ExecutorService } from "../services";
7+import { capture } from "@executor/api";
78
89 export const ToolsHandlers = HttpApiBuilder.group(ExecutorApi, "tools", (handlers) =>
910 handlers
1011 .handle("list", () =>
11− Effect.gen(function* () {
12+ capture(Effect.gen(function* () {
1213 const executor = yield* ExecutorService;
13− const tools = yield* executor.tools.list().pipe(Effect.orDie);
14+ const tools = yield* executor.tools.list();
1415 return tools.map((t) => ({
1516 id: ToolId.make(t.id),
1617 pluginId: t.pluginId,
@@ -19,16 +20,16 @@ export const ToolsHandlers = HttpApiBuilder.group(ExecutorApi, "tools", (handler · 2 unmodified lines
1920 description: t.description,
2021 mayElicit: t.annotations?.mayElicit,
2122 }));
22− }),
23+ })),
2324 )
2425 .handle("schema", ({ path }) =>
25− Effect.gen(function* () {
26+ capture(Effect.gen(function* () {
2627 const executor = yield* ExecutorService;
27− const schema = yield* executor.tools.schema(path.toolId).pipe(Effect.orDie);
28+ const schema = yield* executor.tools.schema(path.toolId);
2829 if (schema === null) {
2930 return yield* Effect.fail(new ToolNotFoundError({ toolId: path.toolId }));
3031 }
3132 return schema;
32− }),
33+ })),
3334 ),
3435 );
packages/core/api/src/index.ts+7−0
@@ -4,3 +4,10 @@ export { SourcesApi } from "./sources/api"; · 3 unmodified lines
44 export { SecretsApi } from "./secrets/api";
55 export { ExecutionsApi } from "./executions/api";
66 export { ScopeApi } from "./scope/api";
7+export {
8+ InternalError,
9+ ErrorCapture,
10+ observabilityMiddleware,
11+ capture,
12+ type ErrorCaptureShape,
13+} from "./observability";
packages/core/api/src/observability.test.ts+87−0
@@ -0,0 +1,87 @@
1+// ---------------------------------------------------------------------------
2+// Edge translation — `StorageError → InternalError(traceId)` behaviour
3+// via `capture(eff)`. Handlers wrap their generator bodies with
4+// `capture(...)`; this file exercises the translator in isolation.
5+// ErrorCapture is optional — absent hosts just get empty trace ids.
6+// ---------------------------------------------------------------------------
7+
8+import { describe, expect, it } from "@effect/vitest";
9+import { Cause, Effect, Layer, Ref } from "effect";
10+import { StorageError, UniqueViolationError } from "@executor/storage-core";
11+
12+import { capture, ErrorCapture, InternalError } from "./observability";
13+
14+// Recording ErrorCapture — returns a fixed trace id and accumulates
15+// every cause it sees in a Ref.
16+const makeRecorder = (traceId = "trace-xyz") =>
17+ Effect.gen(function* () {
18+ const seen = yield* Ref.make<ReadonlyArray<Cause.Cause<unknown>>>([]);
19+ const layer = Layer.succeed(
20+ ErrorCapture,
21+ ErrorCapture.of({
22+ captureException: (cause) =>
23+ Ref.update(seen, (prev) => [...prev, cause]).pipe(
24+ Effect.as(traceId),
25+ ),
26+ }),
27+ );
28+ return { layer, seen };
29+ });
30+
31+describe("capture", () => {
32+ it.effect("translates StorageError to InternalError with ErrorCapture trace id", () =>
33+ Effect.gen(function* () {
34+ const { layer, seen } = yield* makeRecorder("trace-abc");
35+ const err = new StorageError({ message: "db down", cause: new Error("x") });
36+
37+ const eff = capture(Effect.fail(err));
38+ const result = yield* Effect.flip(eff).pipe(Effect.provide(layer));
39+
40+ expect(result).toBeInstanceOf(InternalError);
41+ expect(result.traceId).toBe("trace-abc");
42+
43+ // The recorder saw exactly one cause carrying the original StorageError.
44+ const causes = yield* Ref.get(seen);
45+ expect(causes.length).toBe(1);
46+ const squashed = Cause.squash(causes[0]!) as StorageError;
47+ expect(squashed).toBeInstanceOf(StorageError);
48+ expect(squashed.message).toBe("db down");
49+ }),
50+ );
51+
52+ it.effect("empty traceId when no ErrorCapture is wired", () =>
53+ Effect.gen(function* () {
54+ const err = new StorageError({ message: "nope", cause: undefined });
55+ const result = yield* Effect.flip(capture(Effect.fail(err)));
56+ expect(result).toBeInstanceOf(InternalError);
57+ expect(result.traceId).toBe("");
58+ }),
59+ );
60+
61+ it.effect("UniqueViolationError dies (becomes a defect — plugins should catchTag before returning)", () =>
62+ Effect.gen(function* () {
63+ const err = new UniqueViolationError({ model: "thing" });
64+ const exit = yield* Effect.exit(capture(Effect.fail(err)));
65+ expect(exit._tag).toBe("Failure");
66+ if (exit._tag === "Failure") {
67+ const defects = Cause.defects(exit.cause);
68+ expect(Array.from(defects)[0]).toBeInstanceOf(UniqueViolationError);
69+ }
70+ }),
71+ );
72+
73+ it.effect("non-storage typed failures pass through unchanged", () =>
74+ Effect.gen(function* () {
75+ class DomainError {
76+ readonly _tag = "DomainError" as const;
77+ }
78+ const eff = Effect.fail(new DomainError()) as Effect.Effect<
79+ never,
80+ DomainError
81+ >;
82+ const result = yield* Effect.flip(capture(eff));
83+ expect(result._tag).toBe("DomainError");
84+ }),
85+ );
86+});
87+
packages/core/api/src/observability.ts+155−0
@@ -0,0 +1,155 @@
1+// ---------------------------------------------------------------------------
2+// HTTP-edge observability — singular translation + capture layer.
3+//
4+// The SDK (`@executor/sdk`) stays storage-typed: plugin code and
5+// executor surface methods return `StorageError` in their typed error
6+// channel. Non-HTTP consumers (CLI, Promise SDK, tests) see those raw
7+// and can decide what to do. Here, at the HTTP edge, we define:
8+//
9+// 1. `InternalError` — public opaque 500 schema, narrow by design
10+// (only `traceId`), so no internal cause/message/stack ever
11+// crosses the wire.
12+// 2. `ErrorCapture` — pluggable service the host wires up (Sentry in
13+// the cloud Worker, console in the CLI, in-memory in tests) to
14+// record causes and return correlation ids. Optional; absent →
15+// empty trace ids, nothing breaks.
16+// 3. `capture(eff)` — the one translator. Catches `StorageError` and
17+// `UniqueViolationError` in the typed channel: the former is
18+// captured via `ErrorCapture` and re-failed as `InternalError({
19+// traceId })`; the latter dies as a defect (plugins that want to
20+// surface it as a typed domain error should `Effect.catchTag`
21+// inside their own method first). Every handler wraps its
22+// generator body with `capture(...)` — one line, explicit,
23+// self-enforcing (TypeScript rejects the handler if it forgets).
24+// 4. `observabilityMiddleware` — defect safety net. Wraps the HttpApp
25+// once; catches any cause that slipped past the typed channel and
26+// produces the same `InternalError({ traceId })` shape.
27+//
28+// Distinct from `apps/cloud/src/services/telemetry.ts` — that's the
29+// OTEL bridge wiring spans to Axiom; this is exception capture in the
30+// Sentry sense.
31+// ---------------------------------------------------------------------------
32+
33+import { Cause, Context, Effect, Layer, Option, Schema } from "effect";
34+import {
35+ HttpApiBuilder,
36+ HttpApiSchema,
37+ HttpServerResponse,
38+ type HttpApi,
39+ type HttpApiGroup,
40+} from "@effect/platform";
41+import type { StorageFailure } from "@executor/storage-core";
42+
43+/** Public 500 surface. Opaque by schema. */
44+export class InternalError extends Schema.TaggedError<InternalError>()(
45+ "InternalError",
46+ {
47+ /** Opaque correlation id for backend lookup (Sentry event id, log line, etc.). */
48+ traceId: Schema.String,
49+ },
50+ HttpApiSchema.annotations({ status: 500 }),
51+) {}
52+
53+export interface ErrorCaptureShape {
54+ /**
55+ * Record an unexpected cause and return a correlation id the operator
56+ * can later look up. Implementations (Sentry, console, etc.) decide
57+ * how to persist it.
58+ */
59+ readonly captureException: (
60+ cause: Cause.Cause<unknown>,
61+ ) => Effect.Effect<string>;
62+}
63+
64+export class ErrorCapture extends Context.Tag("@executor/api/ErrorCapture")<
65+ ErrorCapture,
66+ ErrorCaptureShape
67+>() {
68+ /** No-op — used where capture isn't wired. Traces back as empty string. */
69+ static readonly NoOp: Layer.Layer<ErrorCapture> = Layer.succeed(
70+ ErrorCapture,
71+ ErrorCapture.of({ captureException: () => Effect.succeed("") }),
72+ );
73+}
74+
75+// Resolve ErrorCapture with a no-op fallback. Keeps the caller's R channel
76+// unencumbered: no host has to provide ErrorCapture for the wrapper to
77+// typecheck; if it's there, we use it; if not, trace ids are empty.
78+const resolveCapture = Effect.serviceOption(ErrorCapture).pipe(
79+ Effect.map((opt) =>
80+ Option.isSome(opt)
81+ ? opt.value
82+ : ({ captureException: () => Effect.succeed("") } as const),
83+ ),
84+);
85+
86+/**
87+ * HTTP-edge translator for `StorageFailure` on a single Effect. Two
88+ * cases:
89+ *
90+ * - `StorageError` — known backend failure. Capture the cause via
91+ * `ErrorCapture`, fail with `InternalError({ traceId })`.
92+ * - `UniqueViolationError` — invariant violation at the HTTP edge:
93+ * if a plugin wanted to surface a unique-conflict as a typed
94+ * domain error (e.g. "source already exists") it should
95+ * `Effect.catchTag` inside its own method and translate. Anything
96+ * that reaches here is unexpected, so we `Effect.die` and let the
97+ * observability middleware capture it as a defect.
98+ *
99+ * Every other typed failure (plugin-domain errors, etc.) passes
100+ * through unchanged.
101+ */
102+export const capture = <A, E, R>(
103+ eff: Effect.Effect<A, E, R>,
104+): Effect.Effect<A, Exclude<E, StorageFailure> | InternalError, R> =>
105+ (eff as Effect.Effect<A, E | StorageFailure, R>).pipe(
106+ Effect.catchTag("UniqueViolationError", (err) => Effect.die(err)),
107+ Effect.catchTag("StorageError", (err) =>
108+ resolveCapture.pipe(
109+ Effect.flatMap((c) => c.captureException(Cause.fail(err))),
110+ Effect.flatMap((traceId) =>
111+ Effect.fail(new InternalError({ traceId })),
112+ ),
113+ ),
114+ ),
115+ ) as Effect.Effect<A, Exclude<E, StorageFailure> | InternalError, R>;
116+
117+/**
118+ * Edge defect catchall. Builds an `HttpApiBuilder.middleware` layer
119+ * that wraps the HttpApp once. Captures any cause (defects, interrupts,
120+ * unmapped failures the framework couldn't encode) via `ErrorCapture`
121+ * and returns a typed `InternalError({ traceId })` body.
122+ *
123+ * `ErrorCapture` is OPTIONAL — if the host hasn't wired one up the
124+ * middleware still fires but the trace id will be empty.
125+ *
126+ * Should rarely fire when the edge is well-wired — storage failures
127+ * are already translated by `withCapture` at service construction;
128+ * plugin-domain errors flow through their schemas. This is the net
129+ * for anything that slipped through.
130+ */
131+export const observabilityMiddleware = <
132+ Id extends string,
133+ Groups extends HttpApiGroup.HttpApiGroup.Any,
134+ E,
135+ R,
136+>(
137+ api: HttpApi.HttpApi<Id, Groups, E, R>,
138+): Layer.Layer<never> =>
139+ HttpApiBuilder.middleware(
140+ api,
141+ Effect.gen(function* () {
142+ const c = yield* resolveCapture;
143+ return (httpApp) =>
144+ Effect.catchAllCause(httpApp, (cause) =>
145+ Effect.gen(function* () {
146+ const traceId = yield* c.captureException(cause);
147+ return HttpServerResponse.unsafeJson(
148+ new InternalError({ traceId }),
149+ { status: 500 },
150+ );
151+ }),
152+ );
153+ }),
154+ { withContext: true },
155+ );
packages/core/api/src/scope/api.ts+5−3
@@ -2,6 +2,8 @@ import { HttpApiEndpoint, HttpApiGroup } from "@effect/platform"; · 1 unmodified line
22 import { Schema } from "effect";
33 import { ScopeId } from "@executor/sdk";
44
5+import { InternalError } from "../observability";
6+
57 // ---------------------------------------------------------------------------
68 // Response schemas
79 // ---------------------------------------------------------------------------
@@ -16,6 +18,6 @@ const ScopeInfoResponse = Schema.Struct({ · 8 unmodified lines
1618 // Group
1719 // ---------------------------------------------------------------------------
1820
19−export class ScopeApi extends HttpApiGroup.make("scope").add(
20− HttpApiEndpoint.get("info")`/scope`.addSuccess(ScopeInfoResponse),
21−) {}
21+export class ScopeApi extends HttpApiGroup.make("scope")
22+ .add(HttpApiEndpoint.get("info")`/scope`.addSuccess(ScopeInfoResponse))
23+ .addError(InternalError) {}
packages/core/api/src/secrets/api.ts+4−1
@@ -2,6 +2,8 @@ import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "@effect/platform"; · 1 unmodified line
22 import { Schema } from "effect";
33 import { ScopeId, SecretId, SecretNotFoundError, SecretResolutionError } from "@executor/sdk";
44
5+import { InternalError } from "../observability";
6+
57 // ---------------------------------------------------------------------------
68 // Params
79 // ---------------------------------------------------------------------------
@@ -78,4 +80,5 @@ export class SecretsApi extends HttpApiGroup.make("secrets") · 70 unmodified lines
7880 HttpApiEndpoint.del("remove")`/scopes/${scopeIdParam}/secrets/${secretIdParam}`
7981 .addSuccess(Schema.Struct({ removed: Schema.Boolean }))
8082 .addError(SecretNotFound),
81− ) {}
83+ )
84+ .addError(InternalError) {}
packages/core/api/src/services.ts+4−1
@@ -2,7 +2,10 @@ import { Context } from "effect"; · 1 unmodified line
22 import type { Executor } from "@executor/sdk";
33 import type { ExecutionEngine } from "@executor/execution";
44
5−export class ExecutorService extends Context.Tag("ExecutorService")<ExecutorService, Executor>() {}
5+export class ExecutorService extends Context.Tag("ExecutorService")<
6+ ExecutorService,
7+ Executor
8+>() {}
69
710 export class ExecutionEngineService extends Context.Tag("ExecutionEngineService")<
811 ExecutionEngineService,
packages/core/api/src/sources/api.ts+16−5
@@ -1,6 +1,8 @@
11 import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "@effect/platform";
22 import { Schema } from "effect";
3−import { ScopeId, ToolId } from "@executor/sdk";
3+import { ScopeId, SourceRemovalNotAllowedError, ToolId } from "@executor/sdk";
4+
5+import { InternalError } from "../observability";
46
57 // ---------------------------------------------------------------------------
68 // Params
@@ -53,6 +55,14 @@ const DetectResultResponse = Schema.Struct({ · 46 unmodified lines
5355 namespace: Schema.String,
5456 });
5557
58+// ---------------------------------------------------------------------------
59+// Error schemas with HTTP status annotations
60+// ---------------------------------------------------------------------------
61+
62+const SourceRemovalNotAllowed = SourceRemovalNotAllowedError.annotations(
63+ HttpApiSchema.annotations({ status: 409 }),
64+);
65+
5666 // ---------------------------------------------------------------------------
5767 // Group
5868 // ---------------------------------------------------------------------------
@@ -64,9 +74,9 @@ export class SourcesApi extends HttpApiGroup.make("sources") · 5 unmodified lines
6474 ),
6575 )
6676 .add(
67− HttpApiEndpoint.del("remove")`/scopes/${scopeIdParam}/sources/${sourceIdParam}`.addSuccess(
68− SourceRemoveResponse,
69− ),
77+ HttpApiEndpoint.del("remove")`/scopes/${scopeIdParam}/sources/${sourceIdParam}`
78+ .addSuccess(SourceRemoveResponse)
79+ .addError(SourceRemovalNotAllowed),
7080 )
7181 .add(
7282 HttpApiEndpoint.post(
@@ -82,4 +92,5 @@ export class SourcesApi extends HttpApiGroup.make("sources") · 9 unmodified lines
8292 HttpApiEndpoint.post("detect")`/scopes/${scopeIdParam}/sources/detect`
8393 .setPayload(DetectRequest)
8494 .addSuccess(Schema.Array(DetectResultResponse)),
85− ) {}
95+ )
96+ .addError(InternalError) {}
packages/core/api/src/tools/api.ts+4−1
@@ -2,6 +2,8 @@ import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "@effect/platform"; · 1 unmodified line
22 import { Schema } from "effect";
33 import { ScopeId, ToolId, ToolNotFoundError } from "@executor/sdk";
44
5+import { InternalError } from "../observability";
6+
57 // ---------------------------------------------------------------------------
68 // Params
79 // ---------------------------------------------------------------------------
@@ -53,4 +55,5 @@ export class ToolsApi extends HttpApiGroup.make("tools") · 45 unmodified lines
5355 HttpApiEndpoint.get("schema")`/scopes/${scopeIdParam}/tools/${toolIdParam}/schema`
5456 .addSuccess(ToolSchemaResponse)
5557 .addError(ToolNotFound),
56− ) {}
58+ )
59+ .addError(InternalError) {}
packages/core/api/vitest.config.ts+7−0
@@ -0,0 +1,7 @@
1+import { defineConfig } from "vitest/config";
2+
3+export default defineConfig({
4+ test: {
5+ include: ["src/**/*.test.ts"],
6+ },
7+});
packages/core/sdk/package.json+1−0
@@ -44,6 +44,7 @@ · 43 unmodified lines
4444 "typecheck:slow": "tsc --noEmit"
4545 },
4646 "dependencies": {
47+ "@effect/platform": "catalog:",
4748 "@executor/storage-core": "workspace:*",
4849 "effect": "catalog:"
4950 },
packages/core/sdk/src/blob.ts+21−8
@@ -6,35 +6,44 @@ · 5 unmodified lines
66 //
77 // Plugins see a `ScopedBlobStore` that's already namespaced to the plugin
88 // id, so key collisions across plugins are structurally impossible.
9+//
10+// Error channel is `StorageError` — blobs only do read/write/delete, so
11+// they never produce `UniqueViolationError`. The HTTP edge translates
12+// `StorageError` to the opaque public `InternalError({ traceId })`.
913 // ---------------------------------------------------------------------------
1014
1115 import { Effect } from "effect";
1216
17+import { StorageError } from "@executor/storage-core";
18+
1319 export interface BlobStore {
1420 readonly get: (
1521 namespace: string,
1622 key: string,
17− ) => Effect.Effect<string | null, Error>;
23+ ) => Effect.Effect<string | null, StorageError>;
1824 readonly put: (
1925 namespace: string,
2026 key: string,
2127 value: string,
22− ) => Effect.Effect<void, Error>;
28+ ) => Effect.Effect<void, StorageError>;
2329 readonly delete: (
2430 namespace: string,
2531 key: string,
26− ) => Effect.Effect<void, Error>;
32+ ) => Effect.Effect<void, StorageError>;
2733 readonly has: (
2834 namespace: string,
2935 key: string,
30− ) => Effect.Effect<boolean, Error>;
36+ ) => Effect.Effect<boolean, StorageError>;
3137 }
3238
3339 export interface ScopedBlobStore {
34− readonly get: (key: string) => Effect.Effect<string | null, Error>;
35− readonly put: (key: string, value: string) => Effect.Effect<void, Error>;
36− readonly delete: (key: string) => Effect.Effect<void, Error>;
37− readonly has: (key: string) => Effect.Effect<boolean, Error>;
40+ readonly get: (key: string) => Effect.Effect<string | null, StorageError>;
41+ readonly put: (
42+ key: string,
43+ value: string,
44+ ) => Effect.Effect<void, StorageError>;
45+ readonly delete: (key: string) => Effect.Effect<void, StorageError>;
46+ readonly has: (key: string) => Effect.Effect<boolean, StorageError>;
3847 }
3948
4049 export const scopeBlobStore = (
@@ -51,6 +60,10 @@ export const scopeBlobStore = ( · 10 unmodified lines
5160 * Minimal in-memory BlobStore — good for tests and trivial hosts. Real
5261 * backends (filesystem, S3/R2, SQLite-table-backed) implement the same
5362 * interface.
63+ *
64+ * Every method is `Effect<_, never>` — a pure in-memory Map can't fail.
65+ * `never` is assignable to `StorageError`, so the result still fits the
66+ * `BlobStore` interface.
5467 */
5568 export const makeInMemoryBlobStore = (): BlobStore => {
5669 const store = new Map<string, string>();
packages/core/sdk/src/error-handling.test.ts+179−0
@@ -0,0 +1,179 @@
1+// ---------------------------------------------------------------------------
2+// Typed-error model — SDK-level behavioural tests.
3+//
4+// The happy-path tests in executor.test.ts don't exercise storage-failure
5+// propagation because the in-memory adapter never fails. These tests
6+// inject a deliberately failing adapter and verify the SDK surface:
7+//
8+// 1. `StorageError` from a backend surfaces *raw* in the typed error
9+// channel — no telemetry, no InternalError translation. The HTTP
10+// edge (`@executor/api` `withCapture`) is the one layer
11+// that translates to the opaque InternalError; non-HTTP consumers
12+// (CLI, Promise SDK, tests, plugins) can react to the raw tag.
13+// 2. `UniqueViolationError` also passes through raw — plugin code
14+// can `Effect.catchTag` and translate to its own user-facing
15+// typed error.
16+// 3. `createExecutor` has no ErrorCapture requirement at all — no
17+// service lookup, no R channel leak.
18+//
19+// See `notes/error-handling.md` for the architectural overview and
20+// `@executor/api` `observability.test.ts` for the edge-translation
21+// tests.
22+// ---------------------------------------------------------------------------
23+
24+import { describe, expect, it } from "@effect/vitest";
25+import { Effect } from "effect";
26+
27+import {
28+ StorageError,
29+ UniqueViolationError,
30+ type DBAdapter,
31+ type DBTransactionAdapter,
32+ type StorageFailure,
33+} from "@executor/storage-core";
34+import { makeInMemoryBlobStore } from "./blob";
35+import { createExecutor } from "./executor";
36+import { ScopeId } from "./ids";
37+import { defineSchema, definePlugin } from "./plugin";
38+import { Scope } from "./scope";
39+
40+// ---------------------------------------------------------------------------
41+// Test helpers — an adapter that deterministically fails every method
42+// with a chosen StorageFailure, and a scope fixture.
43+// ---------------------------------------------------------------------------
44+
45+const makeFailingAdapter = (failure: StorageFailure): DBAdapter => {
46+ const fail = () =>
47+ Effect.fail(failure) as Effect.Effect<never, StorageFailure, never>;
48+ return {
49+ id: "failing",
50+ create: fail as DBAdapter["create"],
51+ createMany: fail as DBAdapter["createMany"],
52+ findOne: fail as DBAdapter["findOne"],
53+ findMany: fail as DBAdapter["findMany"],
54+ count: fail as DBAdapter["count"],
55+ update: fail as DBAdapter["update"],
56+ updateMany: fail as DBAdapter["updateMany"],
57+ delete: fail as DBAdapter["delete"],
58+ deleteMany: fail as DBAdapter["deleteMany"],
59+ transaction: ((callback: (trx: DBTransactionAdapter) => unknown) => {
60+ void callback;
61+ return Effect.fail(failure);
62+ }) as DBAdapter["transaction"],
63+ };
64+};
65+
66+const testScope = new Scope({
67+ id: ScopeId.make("test-scope"),
68+ name: "test",
69+ createdAt: new Date(),
70+});
71+
72+const baseConfig = (adapter: DBAdapter) => ({
73+ scope: testScope,
74+ adapter,
75+ blobs: makeInMemoryBlobStore(),
76+});
77+
78+// ---------------------------------------------------------------------------
79+// Tests
80+// ---------------------------------------------------------------------------
81+
82+describe("typed-error edge model — SDK", () => {
83+ it.effect("StorageError propagates raw through the executor surface", () =>
84+ Effect.gen(function* () {
85+ const failure = new StorageError({
86+ message: "backend lost its mind",
87+ cause: new Error("driver kaboom"),
88+ });
89+ const executor = yield* createExecutor(
90+ baseConfig(makeFailingAdapter(failure)),
91+ );
92+
93+ const result = yield* executor.tools.list().pipe(Effect.flip);
94+ expect(result).toBeInstanceOf(StorageError);
95+ expect(result._tag).toBe("StorageError");
96+ // Original cause preserved end-to-end.
97+ const storageErr = result as StorageError;
98+ expect(storageErr.message).toBe("backend lost its mind");
99+ expect(storageErr.cause).toBeInstanceOf(Error);
100+ expect((storageErr.cause as Error).message).toBe("driver kaboom");
101+ }),
102+ );
103+
104+ // ---------------------------------------------------------------------------
105+ // UniqueViolationError propagates unchanged — plugins can catchTag it.
106+ // Build a tiny plugin whose storage method calls adapter.create and
107+ // whose extension method exposes a method that catches
108+ // UniqueViolationError and resolves with a sentinel. If the catch
109+ // fires, UniqueViolationError reached the plugin code intact.
110+ // ---------------------------------------------------------------------------
111+
112+ const uniqueSchema = defineSchema({
113+ thing: {
114+ fields: {
115+ id: { type: "string", required: true },
116+ },
117+ },
118+ });
119+
120+ const uniqueTestPlugin = definePlugin(() => ({
121+ id: "uniq-test" as const,
122+ schema: uniqueSchema,
123+ storage: ({ adapter }) => ({
124+ tryCreate: (id: string) =>
125+ adapter
126+ .create({
127+ model: "thing",
128+ data: { id },
129+ forceAllowId: true,
130+ })
131+ .pipe(
132+ Effect.catchTag("UniqueViolationError", (err) =>
133+ Effect.succeed({
134+ caught: true as const,
135+ model: err.model ?? null,
136+ }),
137+ ),
138+ Effect.map((r) =>
139+ "caught" in r ? r : { caught: false as const, model: null },
140+ ),
141+ ),
142+ }),
143+ extension: (ctx) => ({
144+ create: (id: string) => ctx.storage.tryCreate(id),
145+ }),
146+ }));
147+
148+ it.effect("UniqueViolationError propagates through plugin code (catchTag works)", () =>
149+ Effect.gen(function* () {
150+ const failure = new UniqueViolationError({ model: "thing" });
151+ const executor = yield* createExecutor({
152+ ...baseConfig(makeFailingAdapter(failure)),
153+ plugins: [uniqueTestPlugin()] as const,
154+ });
155+
156+ const result = yield* executor["uniq-test"].create("abc");
157+ expect(result.caught).toBe(true);
158+ expect(result.model).toBe("thing");
159+ }),
160+ );
161+
162+ it.effect("createExecutor has no ErrorCapture requirement (no R leak)", () =>
163+ Effect.gen(function* () {
164+ // No `.pipe(Effect.provide(...))` anywhere; just run. If the SDK
165+ // still required any observability service, this wouldn't
166+ // type-check (R would leak) nor run (would crash at construction).
167+ const executor = yield* createExecutor(
168+ baseConfig(
169+ makeFailingAdapter(
170+ new StorageError({ message: "doesn't matter", cause: undefined }),
171+ ),
172+ ),
173+ );
174+ // Sanity: the executor surface exists and methods are callable.
175+ expect(typeof executor.tools.list).toBe("function");
176+ expect(typeof executor.sources.list).toBe("function");
177+ }),
178+ );
179+});
packages/core/sdk/src/executor.test.ts+6−1
@@ -354,8 +354,13 @@ describe("createExecutor", () => { · 353 unmodified lines
354354 }),
355355 );
356356
357+ // The collision is treated as an internal/programmer error and
358+ // surfaces as raw `StorageError` in the typed channel. The HTTP
359+ // edge (`@executor/api` `withCapture`) is responsible for
360+ // translating it to the opaque `InternalError({ traceId })` when
361+ // crossing the wire; here, at the SDK layer, we expect the raw tag.
357362 const err = yield* executor.collide.tryRegister().pipe(Effect.flip);
358− expect(err.message).toContain("collides with a static source");
363+ expect(err._tag).toBe("StorageError");
359364 }),
360365 );
361366
packages/core/sdk/src/executor.ts+85−47
@@ -1,9 +1,11 @@
11 import { Effect, FiberRef } from "effect";
22 import {
3+ StorageError,
34 typedAdapter,
45 type DBAdapter,
56 type DBSchema,
67 type DBTransactionAdapter,
8+ type StorageFailure,
79 type TypedAdapter,
810 } from "@executor/storage-core";
911
@@ -98,19 +100,19 @@ export type Executor<TPlugins extends readonly AnyPlugin[] = []> = { · 88 unmodified lines
98100 readonly tools: {
99101 readonly list: (
100102 filter?: ToolListFilter,
101− ) => Effect.Effect<readonly Tool[], Error>;
103+ ) => Effect.Effect<readonly Tool[], StorageFailure>;
102104 /** Fetch a tool's full schema view: JSON schemas with `$defs`
103105 * attached from the core `definition` table, plus TypeScript
104106 * preview strings rendered from them. Returns `null` for unknown
105107 * tool ids. */
106108 readonly schema: (
107109 toolId: string,
108− ) => Effect.Effect<ToolSchema | null, Error>;
110+ ) => Effect.Effect<ToolSchema | null, StorageFailure>;
109111 /** Every `$defs` entry across every source, grouped by source id.
110112 * Used for bulk schema export and downstream TypeScript rendering. */
111113 readonly definitions: () => Effect.Effect<
112114 Record<string, Record<string, unknown>>,
113− Error
115+ StorageFailure
114116 >;
115117 readonly invoke: (
116118 toolId: string,
@@ -123,44 +125,48 @@ export type Executor<TPlugins extends readonly AnyPlugin[] = []> = { · 6 unmodified lines
123125 | NoHandlerError
124126 | ToolInvocationError
125127 | ElicitationDeclinedError
126− | Error
128+ | StorageFailure
127129 >;
128130 };
129131
130132 readonly sources: {
131− readonly list: () => Effect.Effect<readonly Source[], Error>;
133+ readonly list: () => Effect.Effect<readonly Source[], StorageFailure>;
132134 readonly remove: (
133135 sourceId: string,
134− ) => Effect.Effect<void, SourceRemovalNotAllowedError | Error>;
135− readonly refresh: (sourceId: string) => Effect.Effect<void, Error>;
136+ ) => Effect.Effect<void, SourceRemovalNotAllowedError | StorageFailure>;
137+ readonly refresh: (sourceId: string) => Effect.Effect<void, StorageFailure>;
136138 /** URL autodetection — fans out to every plugin's `detect` hook
137139 * (if declared), returns every high/medium/low-confidence match.
138140 * UI picks a winner from the list. */
139141 readonly detect: (
140142 url: string,
141− ) => Effect.Effect<readonly SourceDetectionResult[], Error>;
143+ ) => Effect.Effect<readonly SourceDetectionResult[], StorageFailure>;
142144 /** All `$defs` registered for a single source, keyed by def name. */
143145 readonly definitions: (
144146 sourceId: string,
145− ) => Effect.Effect<Record<string, unknown>, Error>;
147+ ) => Effect.Effect<Record<string, unknown>, StorageFailure>;
146148 };
147149
148150 readonly secrets: {
149− readonly get: (id: string) => Effect.Effect<string | null, Error>;
151+ readonly get: (
152+ id: string,
153+ ) => Effect.Effect<string | null, StorageFailure>;
150154 /** Fast-path existence check — hits the core `secret` routing table
151155 * only, never calls the provider. Use this for UI state ("secret
152156 * missing, prompt to add") to avoid keychain permission prompts
153157 * or 1password IPC roundtrips on a pre-flight check. */
154158 readonly status: (
155159 id: string,
156− ) => Effect.Effect<"resolved" | "missing", Error>;
157− readonly set: (input: SetSecretInput) => Effect.Effect<SecretRef, Error>;
158− readonly remove: (id: string) => Effect.Effect<void, Error>;
159− readonly list: () => Effect.Effect<readonly SecretRef[], Error>;
160+ ) => Effect.Effect<"resolved" | "missing", StorageFailure>;
161+ readonly set: (
162+ input: SetSecretInput,
163+ ) => Effect.Effect<SecretRef, StorageFailure>;
164+ readonly remove: (id: string) => Effect.Effect<void, StorageFailure>;
165+ readonly list: () => Effect.Effect<readonly SecretRef[], StorageFailure>;
160166 readonly providers: () => Effect.Effect<readonly string[]>;
161167 };
162168
163− readonly close: () => Effect.Effect<void, Error>;
169+ readonly close: () => Effect.Effect<void, StorageFailure>;
164170 } & PluginExtensions<TPlugins>;
165171
166172 export interface ExecutorConfig<
@@ -281,7 +287,7 @@ const writeSourceInput = ( · 114 unmodified lines
281287 core: TypedAdapter<CoreSchema>,
282288 pluginId: string,
283289 input: SourceInput,
284−): Effect.Effect<void, Error> =>
290+): Effect.Effect<void, StorageFailure> =>
285291 Effect.gen(function* () {
286292 yield* deleteSourceById(core, input.id);
287293
@@ -325,7 +331,7 @@ const writeSourceInput = ( · 37 unmodified lines
325331 const deleteSourceById = (
326332 core: TypedAdapter<CoreSchema>,
327333 sourceId: string,
328−): Effect.Effect<void, Error> =>
334+): Effect.Effect<void, StorageFailure> =>
329335 Effect.gen(function* () {
330336 yield* core.deleteMany({
331337 model: "tool",
@@ -345,7 +351,7 @@ const writeDefinitions = ( · 13 unmodified lines
345351 core: TypedAdapter<CoreSchema>,
346352 pluginId: string,
347353 input: DefinitionsInput,
348−): Effect.Effect<void, Error> =>
354+): Effect.Effect<void, StorageFailure> =>
349355 Effect.gen(function* () {
350356 yield* core.deleteMany({
351357 model: "definition",
@@ -505,8 +511,9 @@ export const createExecutor = < · 153 unmodified lines
505511 // without a list() implementation (keychain) never hit the fallback
506512 // walk because their secrets must be registered through set() to
507513 // be known at all.
508− // ------------------------------------------------------------------
509− const secretsGet = (id: string): Effect.Effect<string | null, Error> =>
514+ const secretsGet = (
515+ id: string,
516+ ): Effect.Effect<string | null, StorageFailure> =>
510517 Effect.gen(function* () {
511518 // Fast path: routing table
512519 const row = yield* core.findOne({
@@ -538,15 +545,21 @@ export const createExecutor = < · 25 unmodified lines
538545
539546 const secretsSet = (
540547 input: SetSecretInput,
541− ): Effect.Effect<SecretRef, Error> =>
548+ ): Effect.Effect<SecretRef, StorageFailure> =>
542549 Effect.gen(function* () {
543− // Pick provider: explicit or first-writable.
550+ // Pick provider: explicit or first-writable. Misconfiguration
551+ // (unknown provider, no writable provider, read-only provider)
552+ // is a host setup bug — surface as `StorageError` so it lands
553+ // as a captured InternalError(traceId) at the SDK boundary.
544554 let target: SecretProvider | undefined;
545555 if (input.provider) {
546556 target = secretProviders.get(input.provider);
547557 if (!target) {
548558 return yield* Effect.fail(
549− new Error(`Unknown secret provider: ${input.provider}`),
559+ new StorageError({
560+ message: `Unknown secret provider: ${input.provider}`,
561+ cause: undefined,
562+ }),
550563 );
551564 }
552565 } else {
@@ -558,13 +571,19 @@ export const createExecutor = < · 5 unmodified lines
558571 }
559572 if (!target) {
560573 return yield* Effect.fail(
561− new Error("No writable secret providers registered"),
574+ new StorageError({
575+ message: "No writable secret providers registered",
576+ cause: undefined,
577+ }),
562578 );
563579 }
564580 }
565581 if (!target.writable || !target.set) {
566582 return yield* Effect.fail(
567− new Error(`Secret provider "${target.key}" is read-only`),
583+ new StorageError({
584+ message: `Secret provider "${target.key}" is read-only`,
585+ cause: undefined,
586+ }),
568587 );
569588 }
570589
@@ -596,7 +615,7 @@ export const createExecutor = < · 25 unmodified lines
596615 });
597616 });
598617
599− const secretsRemove = (id: string): Effect.Effect<void, Error> =>
618+ const secretsRemove = (id: string): Effect.Effect<void, StorageFailure> =>
600619 Effect.gen(function* () {
601620 // Providers don't coordinate on which of them own the id — they
602621 // each get asked. Most calls are no-ops; fan them out so one
@@ -630,7 +649,7 @@ export const createExecutor = < · 27 unmodified lines
630649 // so that routing information in the core table is authoritative.
631650 // Providers without a list() method (e.g. keychain) contribute
632651 // only via the core table path.
633− const secretsList = (): Effect.Effect<readonly SecretRef[], Error> =>
652+ const secretsList = (): Effect.Effect<readonly SecretRef[], StorageFailure> =>
634653 Effect.gen(function* () {
635654 const byId = new Map<string, SecretRef>();
636655
@@ -713,11 +732,12 @@ export const createExecutor = < · 76 unmodified lines
713732 );
714733 }
715734
716− // `typedAdapter(adapter)` is a zero-cost cast at the type level —
717− // the runtime value IS the adapter, but the type is whatever the
718− // plugin's schema declares. Plugins never import `typedAdapter` or
719− // `DBAdapter` themselves; they just destructure `{ adapter }` in
720− // their storage factory and get a store typed to their own schema.
735+ // Plugin-facing typed view. `StorageError` and `UniqueViolationError`
736+ // flow through the typed channel unchanged — plugins can
737+ // `catchTag("UniqueViolationError", …)` to translate to their own
738+ // user-facing errors; the HTTP edge (see @executor/api
739+ // `withCapture`) is responsible for translating any
740+ // `StorageError` that still escapes into the opaque InternalError.
721741 // eslint-disable-next-line @typescript-eslint/no-explicit-any
722742 const storageDeps: StorageDeps<any> = {
723743 scope,
@@ -740,21 +760,24 @@ export const createExecutor = < · 16 unmodified lines
740760 // a static source id, or any of whose would-be tool ids
741761 // collide with a static tool id. Tool ids are
742762 // `${source_id}.${tool.name}` — static and dynamic
743− // share the same string space.
763+ // share the same string space. Fails as `StorageError`
764+ // so the HTTP edge surfaces it as `InternalError(traceId)`.
744765 if (staticSources.has(input.id)) {
745766 return yield* Effect.fail(
746− new Error(
747− `Source id "${input.id}" collides with a static source`,
748− ),
767+ new StorageError({
768+ message: `Source id "${input.id}" collides with a static source`,
769+ cause: undefined,
770+ }),
749771 );
750772 }
751773 for (const tool of input.tools) {
752774 const fqid = `${input.id}.${tool.name}`;
753775 if (staticTools.has(fqid)) {
754776 return yield* Effect.fail(
755− new Error(
756− `Tool id "${fqid}" collides with a static tool`,
757− ),
777+ new StorageError({
778+ message: `Tool id "${fqid}" collides with a static tool`,
779+ cause: undefined,
780+ }),
758781 );
759782 }
760783 }
@@ -779,16 +802,21 @@ export const createExecutor = < · 18 unmodified lines
779802 },
780803 },
781804 secrets: {
782− get: secretsGet,
783− list: secretsListForCtx,
784− set: secretsSet,
785− remove: secretsRemove,
805+ get: (id) => secretsGet(id),
806+ list: () => secretsListForCtx(),
807+ set: (input) => secretsSet(input),
808+ remove: (id) => secretsRemove(id),
786809 },
787810 // Open one real tx boundary and route every nested write inside
788811 // `effect` through that same handle via the activeAdapterRef —
789− // see buildAdapterRouter above for the dispatch logic.
812+ // see buildAdapterRouter above. Caller-typed errors (`E`)
813+ // propagate unchanged; storage failures also stay typed
814+ // (`StorageFailure`) so the HTTP edge wrapper can translate them.
790815 transaction: <A, E>(effect: Effect.Effect<A, E>) =>
791− adapter.transaction(() => effect) as Effect.Effect<A, E | Error>,
816+ adapter.transaction(() => effect) as Effect.Effect<
817+ A,
818+ E | StorageFailure
819+ >,
792820 };
793821
794822 // Build extension FIRST so it's available as `self` when resolving
@@ -1236,7 +1264,7 @@ export const createExecutor = < · 441 unmodified lines
12361264 // just to ask "does this exist?"
12371265 const secretsStatus = (
12381266 id: string,
1239− ): Effect.Effect<"resolved" | "missing", Error> =>
1267+ ): Effect.Effect<"resolved" | "missing", StorageFailure> =>
12401268 Effect.gen(function* () {
12411269 const row = yield* core.findOne({
12421270 model: "secret",
@@ -1263,6 +1291,11 @@ export const createExecutor = < · 20 unmodified lines
12631291 }
12641292 });
12651293
1294+ // Public Executor surface — storage-backed methods surface
1295+ // `StorageFailure` (StorageError | UniqueViolationError) raw. The
1296+ // HTTP edge wraps this surface with `withCapture` to
1297+ // translate `StorageError` → `InternalError({ traceId })`; non-HTTP
1298+ // consumers (CLI, Promise SDK, tests) see the raw typed channel.
12661299 const base = {
12671300 scope,
12681301 tools: {
@@ -1292,5 +1325,10 @@ export const createExecutor = < · 23 unmodified lines
12921325 close,
12931326 };
12941327
1295− return Object.assign(base, extensions) as Executor<TPlugins>;
1328+ // Cast through `unknown` because the impl effects can include
1329+ // `Error` from plugin-supplied callbacks (resolveAnnotations etc.) —
1330+ // those leak via the helper functions and won't be cleaned until
1331+ // every plugin tightens its surface to typed errors. The runtime
1332+ // shape matches `Executor<TPlugins>`.
1333+ return Object.assign(base, extensions) as unknown as Executor<TPlugins>;
12961334 });
packages/core/sdk/src/index.ts+5−0
@@ -10,6 +10,7 @@ export type { · 9 unmodified lines
1010 DBSchema,
1111 DBFieldAttribute,
1212 DBFieldType,
13+ StorageFailure,
1314 TypedAdapter,
1415 Where,
1516 WhereOperator,
@@ -17,6 +18,10 @@ export type { · 1 unmodified line
1718
1819 export { typedAdapter } from "@executor/storage-core";
1920
21+// Storage-layer typed errors (re-exported so plugin code can catchTag
22+// `UniqueViolationError` without importing storage-core directly).
23+export { StorageError, UniqueViolationError } from "@executor/storage-core";
24+
2025 // IDs (branded)
2126 export { ScopeId, ToolId, SecretId, PolicyId } from "./ids";
2227
packages/core/sdk/src/plugin.ts+35−16
@@ -2,6 +2,7 @@ import type { Effect } from "effect"; · 1 unmodified line
22 import type {
33 DBAdapter,
44 DBSchema,
5+ StorageFailure,
56 TypedAdapter,
67 } from "@executor/storage-core";
78
@@ -36,7 +37,17 @@ import type { SecretProvider, SecretRef, SetSecretInput } from "./secrets"; · 28 unmodified lines
3637
3738 export interface StorageDeps<TSchema extends DBSchema | undefined = undefined> {
3839 readonly scope: Scope;
39− readonly adapter: TSchema extends DBSchema ? TypedAdapter<TSchema> : DBAdapter;
40+ /**
41+ * Plugin-facing typed adapter. Failures surface as raw `StorageFailure`
42+ * (`StorageError` | `UniqueViolationError`). Plugins can
43+ * `catchTag("UniqueViolationError", …)` to translate to their own
44+ * user-facing errors. `StorageError` bubbles up; the HTTP edge (see
45+ * `@executor/api` `withCapture`) is the one place that
46+ * translates it to the opaque `InternalError({ traceId })`.
47+ */
48+ readonly adapter: TSchema extends DBSchema
49+ ? TypedAdapter<TSchema, StorageFailure>
50+ : DBAdapter;
4051 readonly blobs: ScopedBlobStore;
4152 }
4253
@@ -72,8 +83,12 @@ export interface PluginCtx<TStore = unknown> { · 29 unmodified lines
7283
7384 readonly core: {
7485 readonly sources: {
75− readonly register: (input: SourceInput) => Effect.Effect<void, Error>;
76− readonly unregister: (sourceId: string) => Effect.Effect<void, Error>;
86+ readonly register: (
87+ input: SourceInput,
88+ ) => Effect.Effect<void, StorageFailure>;
89+ readonly unregister: (
90+ sourceId: string,
91+ ) => Effect.Effect<void, StorageFailure>;
7792 };
7893 /** Register shared JSON-schema `$defs` for a source. Tool
7994 * input/output schemas registered via `sources.register` can carry
@@ -84,15 +99,17 @@ export interface PluginCtx<TStore = unknown> { · 4 unmodified lines
8499 readonly definitions: {
85100 readonly register: (
86101 input: DefinitionsInput,
87− ) => Effect.Effect<void, Error>;
102+ ) => Effect.Effect<void, StorageFailure>;
88103 };
89104 };
90105
91106 readonly secrets: {
92− readonly get: (id: string) => Effect.Effect<string | null, Error>;
107+ readonly get: (
108+ id: string,
109+ ) => Effect.Effect<string | null, StorageFailure>;
93110 readonly list: () => Effect.Effect<
94111 readonly { readonly id: string; readonly name: string; readonly provider: string }[],
95− Error
112+ StorageFailure
96113 >;
97114 /** Write a secret value through a provider. Used by plugins that
98115 * mint secrets on behalf of the user (OAuth2 token storage,
@@ -100,9 +117,11 @@ export interface PluginCtx<TStore = unknown> { · 1 unmodified line
100117 * `executor.secrets.set` on the host surface, but OAuth2 refresh
101118 * and one-shot token capture from plugin-owned flows need it here
102119 * too. Same routing rules as the host-level setter. */
103− readonly set: (input: SetSecretInput) => Effect.Effect<SecretRef, Error>;
120+ readonly set: (
121+ input: SetSecretInput,
122+ ) => Effect.Effect<SecretRef, StorageFailure>;
104123 /** Delete a secret from its pinned provider and the core table. */
105− readonly remove: (id: string) => Effect.Effect<void, Error>;
124+ readonly remove: (id: string) => Effect.Effect<void, StorageFailure>;
106125 };
107126
108127 /** Run `effect` inside a database transaction. Wraps the underlying
@@ -111,7 +130,7 @@ export interface PluginCtx<TStore = unknown> { · 2 unmodified lines
111130 * registration. */
112131 readonly transaction: <A, E>(
113132 effect: Effect.Effect<A, E>,
114− ) => Effect.Effect<A, E | Error>;
133+ ) => Effect.Effect<A, E | StorageFailure>;
115134 }
116135
117136 // ---------------------------------------------------------------------------
@@ -146,7 +165,7 @@ export interface StaticToolDecl<TStore = unknown> { · 28 unmodified lines
146165 readonly annotations?: ToolAnnotations;
147166 readonly handler: (
148167 input: StaticToolHandlerInput<TStore>,
149− ) => Effect.Effect<unknown, Error>;
168+ ) => Effect.Effect<unknown, unknown>;
150169 }
151170
152171 export interface StaticSourceDecl<TStore = unknown> {
@@ -228,7 +247,7 @@ export interface PluginSpec< · 75 unmodified lines
228247 * only static tools can omit it. */
229248 readonly invokeTool?: (
230249 input: InvokeToolInput<TStore>,
231− ) => Effect.Effect<unknown, Error>;
250+ ) => Effect.Effect<unknown, unknown>;
232251
233252 /** Bulk resolve annotations (requiresApproval, approvalDescription,
234253 * mayElicit) for a set of tool rows under a single source. Called
@@ -251,7 +270,7 @@ export interface PluginSpec< · 16 unmodified lines
251270 readonly ctx: PluginCtx<TStore>;
252271 readonly sourceId: string;
253272 readonly toolRows: readonly ToolRow[];
254− }) => Effect.Effect<Record<string, ToolAnnotations>, Error>;
273+ }) => Effect.Effect<Record<string, ToolAnnotations>, unknown>;
255274
256275 /** Called when `executor.sources.remove(id)` targets a source owned
257276 * by this plugin. Plugin-side cleanup only; the executor deletes
@@ -259,11 +278,11 @@ export interface PluginSpec< · 1 unmodified line
259278 * the same transaction. */
260279 readonly removeSource?: (
261280 input: SourceLifecycleInput<TStore>,
262− ) => Effect.Effect<void, Error>;
281+ ) => Effect.Effect<void, unknown>;
263282
264283 readonly refreshSource?: (
265284 input: SourceLifecycleInput<TStore>,
266− ) => Effect.Effect<void, Error>;
285+ ) => Effect.Effect<void, unknown>;
267286
268287 /** URL autodetection hook. When the user pastes a URL in the
269288 * onboarding UI, `executor.sources.detect(url)` fans out to every
@@ -274,7 +293,7 @@ export interface PluginSpec< · 4 unmodified lines
274293 readonly detect?: (input: {
275294 readonly ctx: PluginCtx<TStore>;
276295 readonly url: string;
277− }) => Effect.Effect<SourceDetectionResult | null, Error>;
296+ }) => Effect.Effect<SourceDetectionResult | null, unknown>;
278297
279298 /** Secret providers contributed by this plugin. Either a static
280299 * array or a function of ctx (for providers that need per-instance
@@ -285,7 +304,7 @@ export interface PluginSpec< · 4 unmodified lines
285304 | readonly SecretProvider[]
286305 | ((ctx: PluginCtx<TStore>) => readonly SecretProvider[]);
287306
288− readonly close?: () => Effect.Effect<void, Error>;
307+ readonly close?: () => Effect.Effect<void, unknown>;
289308 }
290309
291310 export interface Plugin<
packages/core/sdk/src/promise-executor.ts+6−0
@@ -125,6 +125,12 @@ export const createExecutor = async < · 124 unmodified lines
125125 plugins,
126126 };
127127
128+ // The SDK has no observability requirement; storage failures surface
129+ // as raw `StorageError` / `UniqueViolationError` in the typed channel.
130+ // `Effect.runPromise` turns them into Promise rejections — consumers
131+ // get the tagged error as the rejected value. See
132+ // notes/promise-sdk-typed-errors.md for the planned `runPromiseExit`
133+ // rewrite that exposes the full error union to consumers.
128134 const effectExecutor = await Effect.runPromise(
129135 createEffectExecutor(effectConfig),
130136 );
packages/core/sdk/src/secrets.ts+14−6
@@ -1,5 +1,7 @@
11 import { Effect, Schema } from "effect";
22
3+import type { StorageFailure } from "@executor/storage-core";
4+
35 import { SecretId, ScopeId } from "./ids";
46
57 // ---------------------------------------------------------------------------
@@ -20,19 +22,25 @@ export interface SecretProvider { · 14 unmodified lines
2022 * honours this before routing writes — trying to write to a
2123 * read-only provider is an error, not a silent drop. */
2224 readonly writable: boolean;
23− /** Get a secret value by id. Returns null if not found. Errors are
24− * real failures (provider unreachable, decryption failed, etc.). */
25− readonly get: (id: string) => Effect.Effect<string | null, Error>;
25+ /** Get a secret value by id. Returns null if not found. Failures
26+ * (provider unreachable, decryption failed, etc.) surface as
27+ * `StorageFailure` — the executor treats a provider call the same
28+ * as a DB call; `StorageError` is captured at the HTTP edge to
29+ * `InternalError`, `UniqueViolationError` dies. */
30+ readonly get: (id: string) => Effect.Effect<string | null, StorageFailure>;
2631 /** Set a secret value. Only called on writable providers. */
27− readonly set?: (id: string, value: string) => Effect.Effect<void, Error>;
32+ readonly set?: (
33+ id: string,
34+ value: string,
35+ ) => Effect.Effect<void, StorageFailure>;
2836 /** Delete a secret. Only called on writable providers. Returns true
2937 * if something was deleted. */
30− readonly delete?: (id: string) => Effect.Effect<boolean, Error>;
38+ readonly delete?: (id: string) => Effect.Effect<boolean, StorageFailure>;
3139 /** Enumerate known secret entries. Optional — not all backends can
3240 * enumerate (env-backed providers, for example). */
3341 readonly list?: () => Effect.Effect<
3442 readonly { readonly id: string; readonly name: string }[],
35− Error
43+ StorageFailure
3644 >;
3745 }
3846
packages/core/storage-core/src/adapter.ts+28−24
@@ -5,8 +5,12 @@ · 4 unmodified lines
55
66 import type { Effect } from "effect";
77
8+import type { StorageError, UniqueViolationError } from "./errors";
89 import type { DBFieldAttribute, DBSchema } from "./schema";
910
11+/** Error union every backend method may emit. */
12+export type StorageFailure = StorageError | UniqueViolationError;
13+
1014 // ---------------------------------------------------------------------------
1115 // Where clauses
1216 // ---------------------------------------------------------------------------
@@ -102,7 +106,7 @@ export type DBAdapter = { · 89 unmodified lines
102106 select?: string[] | undefined;
103107 /** Preserve an `id` in `data` instead of discarding it. */
104108 forceAllowId?: boolean | undefined;
105− }) => Effect.Effect<R, Error>;
109+ }) => Effect.Effect<R, StorageFailure>;
106110
107111 /**
108112 * Insert multiple rows in one call. Backends that don't have native
@@ -113,14 +117,14 @@ export type DBAdapter = { · 4 unmodified lines
113117 model: string;
114118 data: ReadonlyArray<Omit<T, "id">>;
115119 forceAllowId?: boolean | undefined;
116− }) => Effect.Effect<readonly R[], Error>;
120+ }) => Effect.Effect<readonly R[], StorageFailure>;
117121
118122 findOne: <T>(data: {
119123 model: string;
120124 where: Where[];
121125 select?: string[] | undefined;
122126 join?: JoinOption | undefined;
123− }) => Effect.Effect<T | null, Error>;
127+ }) => Effect.Effect<T | null, StorageFailure>;
124128
125129 findMany: <T>(data: {
126130 model: string;
@@ -135,12 +139,12 @@ export type DBAdapter = { · 8 unmodified lines
135139 | undefined;
136140 offset?: number | undefined;
137141 join?: JoinOption | undefined;
138− }) => Effect.Effect<readonly T[], Error>;
142+ }) => Effect.Effect<readonly T[], StorageFailure>;
139143
140144 count: (data: {
141145 model: string;
142146 where?: Where[] | undefined;
143− }) => Effect.Effect<number, Error>;
147+ }) => Effect.Effect<number, StorageFailure>;
144148
145149 /**
146150 * ⚠︎ `update` may return `null` if multiple rows match — prefer `updateMany`
@@ -150,17 +154,17 @@ export type DBAdapter = { · 3 unmodified lines
150154 model: string;
151155 where: Where[];
152156 update: Record<string, unknown>;
153− }) => Effect.Effect<T | null, Error>;
157+ }) => Effect.Effect<T | null, StorageFailure>;
154158
155159 updateMany: (data: {
156160 model: string;
157161 where: Where[];
158162 update: Record<string, unknown>;
159− }) => Effect.Effect<number, Error>;
163+ }) => Effect.Effect<number, StorageFailure>;
160164
161− delete: (data: { model: string; where: Where[] }) => Effect.Effect<void, Error>;
165+ delete: (data: { model: string; where: Where[] }) => Effect.Effect<void, StorageFailure>;
162166
163− deleteMany: (data: { model: string; where: Where[] }) => Effect.Effect<number, Error>;
167+ deleteMany: (data: { model: string; where: Where[] }) => Effect.Effect<number, StorageFailure>;
164168
165169 /**
166170 * Run operations in a transaction. Backends without transaction support
@@ -168,7 +172,7 @@ export type DBAdapter = { · 1 unmodified line
168172 */
169173 transaction: <R, E>(
170174 callback: (trx: DBTransactionAdapter) => Effect.Effect<R, E>,
171− ) => Effect.Effect<R, E | Error>;
175+ ) => Effect.Effect<R, E | StorageFailure>;
172176
173177 /**
174178 * Optional migration-source generator. Forwarded verbatim from the
@@ -179,7 +183,7 @@ export type DBAdapter = { · 4 unmodified lines
179183 | ((props: {
180184 file?: string | undefined;
181185 tables: DBSchema;
182− }) => Effect.Effect<DBAdapterSchemaCreation, Error>)
186+ }) => Effect.Effect<DBAdapterSchemaCreation, StorageFailure>)
183187 | undefined;
184188
185189 /**
@@ -205,7 +209,7 @@ export interface CustomAdapter { · 19 unmodified lines
205209 model: string;
206210 data: T;
207211 select?: string[] | undefined;
208− }) => Effect.Effect<T, Error>;
212+ }) => Effect.Effect<T, StorageFailure>;
209213
210214 /**
211215 * Native bulk insert. Required because over a real network (e.g.
@@ -217,26 +221,26 @@ export interface CustomAdapter { · 5 unmodified lines
217221 createMany: <T extends Record<string, unknown>>(data: {
218222 model: string;
219223 data: ReadonlyArray<T>;
220− }) => Effect.Effect<T[], Error>;
224+ }) => Effect.Effect<T[], StorageFailure>;
221225
222226 update: <T>(data: {
223227 model: string;
224228 where: CleanedWhere[];
225229 update: T;
226− }) => Effect.Effect<T | null, Error>;
230+ }) => Effect.Effect<T | null, StorageFailure>;
227231
228232 updateMany: (data: {
229233 model: string;
230234 where: CleanedWhere[];
231235 update: Record<string, unknown>;
232− }) => Effect.Effect<number, Error>;
236+ }) => Effect.Effect<number, StorageFailure>;
233237
234238 findOne: <T>(data: {
235239 model: string;
236240 where: CleanedWhere[];
237241 select?: string[] | undefined;
238242 join?: JoinConfig | undefined;
239− }) => Effect.Effect<T | null, Error>;
243+ }) => Effect.Effect<T | null, StorageFailure>;
240244
241245 findMany: <T>(data: {
242246 model: string;
@@ -246,22 +250,22 @@ export interface CustomAdapter { · 3 unmodified lines
246250 sortBy?: { field: string; direction: "asc" | "desc" } | undefined;
247251 offset?: number | undefined;
248252 join?: JoinConfig | undefined;
249− }) => Effect.Effect<T[], Error>;
253+ }) => Effect.Effect<T[], StorageFailure>;
250254
251255 delete: (data: {
252256 model: string;
253257 where: CleanedWhere[];
254− }) => Effect.Effect<void, Error>;
258+ }) => Effect.Effect<void, StorageFailure>;
255259
256260 deleteMany: (data: {
257261 model: string;
258262 where: CleanedWhere[];
259− }) => Effect.Effect<number, Error>;
263+ }) => Effect.Effect<number, StorageFailure>;
260264
261265 count: (data: {
262266 model: string;
263267 where?: CleanedWhere[] | undefined;
264− }) => Effect.Effect<number, Error>;
268+ }) => Effect.Effect<number, StorageFailure>;
265269
266270 /**
267271 * Optional migration-source generator — plugin authors use this to
@@ -272,7 +276,7 @@ export interface CustomAdapter { · 4 unmodified lines
272276 | ((props: {
273277 file?: string | undefined;
274278 tables: DBSchema;
275− }) => Effect.Effect<DBAdapterSchemaCreation, Error>)
279+ }) => Effect.Effect<DBAdapterSchemaCreation, StorageFailure>)
276280 | undefined;
277281
278282 options?: Record<string, unknown> | undefined;
@@ -321,7 +325,7 @@ export interface DBAdapterFactoryConfig { · 42 unmodified lines
321325 | false
322326 | (<R, E>(
323327 callback: (trx: DBTransactionAdapter) => Effect.Effect<R, E>,
324− ) => Effect.Effect<R, E | Error>)
328+ ) => Effect.Effect<R, E | StorageFailure>)
325329 )
326330 | undefined;
327331 /** Skip id generation on `create`. @default false */
@@ -354,7 +358,7 @@ export interface DBAdapterFactoryConfig { · 26 unmodified lines
354358 | "count";
355359 model: string;
356360 schema: DBSchema;
357− }) => Effect.Effect<unknown, Error>)
361+ }) => Effect.Effect<unknown, StorageFailure>)
358362 | undefined;
359363 /**
360364 * Per-field hook run inside the factory's read-path transform. Called
@@ -369,7 +373,7 @@ export interface DBAdapterFactoryConfig { · 8 unmodified lines
369373 select: readonly string[];
370374 model: string;
371375 schema: DBSchema;
372− }) => Effect.Effect<unknown, Error>)
376+ }) => Effect.Effect<unknown, StorageFailure>)
373377 | undefined;
374378 /** Escape hatch — skip write-path transform entirely. @default false */
375379 disableTransformInput?: boolean | undefined;
packages/core/storage-core/src/errors.ts+42−0
@@ -0,0 +1,42 @@
1+// ---------------------------------------------------------------------------
2+// Storage-layer typed errors.
3+//
4+// Both are `Data.TaggedError` — runtime values, not wire schemas.
5+// Storage-core stays out of HTTP / serialisation / telemetry concerns;
6+// the HTTP edge (`@executor/api` `withCapture`) is the one place
7+// that translates `StorageError` into the opaque public
8+// `InternalError({ traceId })`. Plugins `Effect.catchTag("UniqueViolationError")`
9+// and re-fail with their own schema'd error (e.g. `McpSourceAlreadyExistsError`).
10+//
11+// The `Data` choice (vs `Schema.TaggedError`) is enforcement: it's
12+// physically impossible to `addError(...)` these on an HttpApi group, so
13+// nobody can accidentally leak storage-layer details to clients by
14+// letting them serialize through.
15+// ---------------------------------------------------------------------------
16+
17+import { Data } from "effect";
18+
19+/**
20+ * Catch-all for non-recoverable backend failures (driver crash, network
21+ * gone, transaction abort the backend can't classify, etc.). The cause
22+ * travels as runtime data so the HTTP edge can capture it via
23+ * `ErrorCapture` before translating to the public `InternalError`.
24+ */
25+export class StorageError extends Data.TaggedError("StorageError")<{
26+ readonly message: string;
27+ readonly cause: unknown;
28+}> {}
29+
30+/**
31+ * Typed unique-constraint violation. Plugins are expected to
32+ * `Effect.catchTag` this and translate to their own user-facing error.
33+ * Carries an optional `model` so a plugin doing a batch insert across
34+ * tables can disambiguate; everything else (constraint name, raw
35+ * driver message) stays internal because plugin code rarely needs it
36+ * and surfacing it leaks backend specifics.
37+ */
38+export class UniqueViolationError extends Data.TaggedError(
39+ "UniqueViolationError",
40+)<{
41+ readonly model?: string;
42+}> {}
packages/core/storage-core/src/factory.ts+25−16
@@ -3,7 +3,7 @@ · 2 unmodified lines
33 //
44 // Vendored from better-auth (packages/core/src/db/adapter/factory.ts) under
55 // MIT. Adapted for executor:
6−// - Promise/async → Effect.Effect<T, Error>
6+// - Promise/async → Effect.Effect<T, StorageFailure>
77 // - Stripped auth-specific concerns (numeric serial ids, joins, telemetry
88 // spans, logger, plural model name resolution, BetterAuthOptions)
99 // - Contract matches our CustomAdapter + DBAdapterFactoryConfig in
@@ -35,8 +35,10 @@ import type { · 25 unmodified lines
3535 DBTransactionAdapter,
3636 JoinConfig,
3737 JoinOption,
38+ StorageFailure,
3839 Where,
3940 } from "./adapter";
41+import { StorageError } from "./errors";
4042 import type { DBFieldAttribute, DBSchema, DBPrimitive } from "./schema";
4143
4244 // ---------------------------------------------------------------------------
@@ -124,12 +126,17 @@ export const createAdapter = ( · 81 unmodified lines
124126 return defaultGenerateId();
125127 };
126128
127− const getModelDef = (model: string): Effect.Effect<DBSchema[string], Error> =>
129+ const getModelDef = (
130+ model: string,
131+ ): Effect.Effect<DBSchema[string], StorageError> =>
128132 Effect.gen(function* () {
129133 const def = schema[model];
130134 if (!def) {
131135 return yield* Effect.fail(
132− new Error(`[storage-core] unknown model "${model}"`),
136+ new StorageError({
137+ message: `[storage-core] unknown model "${model}"`,
138+ cause: undefined,
139+ }),
133140 );
134141 }
135142 return def;
@@ -258,7 +265,7 @@ export const createAdapter = ( · 122 unmodified lines
258265 data: Record<string, unknown>,
259266 action: "create" | "update",
260267 forceAllowId: boolean,
261− ): Effect.Effect<Record<string, unknown>, Error> =>
268+ ): Effect.Effect<Record<string, unknown>, StorageFailure> =>
262269 Effect.gen(function* () {
263270 const def = yield* getModelDef(model);
264271 const out: Record<string, unknown> = {};
@@ -303,10 +310,11 @@ export const createAdapter = ( · 38 unmodified lines
303310 if (res && typeof (res as { then?: unknown }).then === "function") {
304311 value = yield* Effect.tryPromise({
305312 try: () => res as Promise<DBPrimitive>,
306− catch: (e) =>
307− new Error(
308− `[storage-core] transform.input for "${model}.${logical}" failed: ${String(e)}`,
309− ),
313+ catch: (cause) =>
314+ new StorageError({
315+ message: `[storage-core] transform.input for "${model}.${logical}" failed: ${cause instanceof Error ? cause.message : String(cause)}`,
316+ cause,
317+ }),
310318 });
311319 } else {
312320 value = res;
@@ -347,7 +355,7 @@ export const createAdapter = ( · 34 unmodified lines
347355 model: string,
348356 row: Record<string, unknown> | null,
349357 select?: string[],
350− ): Effect.Effect<Record<string, unknown> | null, Error> =>
358+ ): Effect.Effect<Record<string, unknown> | null, StorageFailure> =>
351359 Effect.gen(function* () {
352360 if (row === null || row === undefined) return null;
353361 const def = yield* getModelDef(model);
@@ -377,10 +385,11 @@ export const createAdapter = ( · 23 unmodified lines
377385 if (res && typeof (res as { then?: unknown }).then === "function") {
378386 value = yield* Effect.tryPromise({
379387 try: () => res as Promise<DBPrimitive>,
380− catch: (e) =>
381− new Error(
382− `[storage-core] transform.output for "${model}.${logical}" failed: ${String(e)}`,
383− ),
388+ catch: (cause) =>
389+ new StorageError({
390+ message: `[storage-core] transform.output for "${model}.${logical}" failed: ${cause instanceof Error ? cause.message : String(cause)}`,
391+ cause,
392+ }),
384393 });
385394 } else {
386395 value = res;
@@ -532,7 +541,7 @@ export const createAdapter = ( · 145 unmodified lines
532541 data: Record<string, unknown>,
533542 action: "create" | "update",
534543 forceAllowId: boolean,
535− ): Effect.Effect<Record<string, unknown>, Error> =>
544+ ): Effect.Effect<Record<string, unknown>, StorageFailure> =>
536545 config.disableTransformInput
537546 ? Effect.succeed(data)
538547 : transformInput(model, data, action, forceAllowId);
@@ -551,7 +560,7 @@ export const createAdapter = ( · 12 unmodified lines
551560 base: Record<string, unknown> | null,
552561 raw: Record<string, unknown> | null,
553562 join: JoinOption | undefined,
554− ): Effect.Effect<Record<string, unknown> | null, Error> =>
563+ ): Effect.Effect<Record<string, unknown> | null, StorageFailure> =>
555564 Effect.gen(function* () {
556565 if (!base || !raw || !join) return base;
557566 const merged: Record<string, unknown> = { ...base };
@@ -593,7 +602,7 @@ export const createAdapter = ( · 35 unmodified lines
593602 model: string,
594603 row: Record<string, unknown> | null,
595604 select?: string[],
596− ): Effect.Effect<Record<string, unknown> | null, Error> =>
605+ ): Effect.Effect<Record<string, unknown> | null, StorageFailure> =>
597606 config.disableTransformOutput
598607 ? Effect.succeed(row)
599608 : transformOutput(model, row, select);
packages/core/storage-core/src/index.ts+3−0
@@ -15,6 +15,8 @@ export { typedAdapter, type TypedAdapter } from "./typed"; · 14 unmodified lines
1515
1616 export { createAdapter, type CreateAdapterOptions } from "./factory";
1717
18+export { StorageError, UniqueViolationError } from "./errors";
19+
1820 export {
1921 whereOperators,
2022 type WhereOperator,
@@ -28,4 +30,5 @@ export { · 7 unmodified lines
2830 type DBAdapterDebugLogOption,
2931 type DBAdapterFactoryConfig,
3032 type DBAdapterSchemaCreation,
33+ type StorageFailure,
3134 } from "./adapter";
packages/core/storage-core/src/testing/memory.ts+2−1
@@ -22,6 +22,7 @@ import type { · 21 unmodified lines
2222 DBAdapter,
2323 DBAdapterFactoryConfig,
2424 JoinConfig,
25+ StorageFailure,
2526 } from "../adapter";
2627 import type { DBSchema } from "../schema";
2728 import { createAdapter } from "../factory";
@@ -266,7 +267,7 @@ export const makeMemoryAdapter = ( · 238 unmodified lines
266267 }),
267268 );
268269 return result;
269− }) as Effect.Effect<R, E | Error>;
270+ }) as Effect.Effect<R, E | StorageFailure>;
270271
271272 const adapter: DBAdapter = createAdapter({
272273 schema: options.schema,
packages/core/storage-core/src/typed.ts+19−11
@@ -17,7 +17,7 @@ · 16 unmodified lines
1717
1818 import type { Effect } from "effect";
1919
20−import type { DBAdapter, Where } from "./adapter";
20+import type { DBAdapter, StorageFailure, Where } from "./adapter";
2121 import type {
2222 DBSchema,
2323 InferDBFieldsInput,
@@ -34,25 +34,33 @@ type RowOutput<S extends DBSchema, M extends keyof S> = InferDBFieldsOutput< · 10 unmodified lines
3434 > &
3535 Record<string, unknown>;
3636
37−export interface TypedAdapter<S extends DBSchema> {
37+/**
38+ * Schema-typed view over a `DBAdapter`. The error parameter `E`
39+ * defaults to `StorageFailure` (`StorageError | UniqueViolationError`) —
40+ * that's what backends emit and what plugin code sees. Translation
41+ * to the opaque public `InternalError({ traceId })` is done only at
42+ * the HTTP edge (`@executor/api` `withCapture`); everyone else
43+ * works with the raw tag.
44+ */
45+export interface TypedAdapter<S extends DBSchema, E = StorageFailure> {
3846 readonly raw: DBAdapter;
3947
4048 readonly create: <M extends keyof S & string>(data: {
4149 model: M;
4250 data: Omit<RowInput<S, M>, "id"> & { id?: string };
4351 forceAllowId?: boolean;
44− }) => Effect.Effect<RowOutput<S, M>, Error>;
52+ }) => Effect.Effect<RowOutput<S, M>, E>;
4553
4654 readonly createMany: <M extends keyof S & string>(data: {
4755 model: M;
4856 data: ReadonlyArray<Omit<RowInput<S, M>, "id"> & { id?: string }>;
4957 forceAllowId?: boolean;
50− }) => Effect.Effect<readonly RowOutput<S, M>[], Error>;
58+ }) => Effect.Effect<readonly RowOutput<S, M>[], E>;
5159
5260 readonly findOne: <M extends keyof S & string>(data: {
5361 model: M;
5462 where: Where[];
55− }) => Effect.Effect<RowOutput<S, M> | null, Error>;
63+ }) => Effect.Effect<RowOutput<S, M> | null, E>;
5664
5765 readonly findMany: <M extends keyof S & string>(data: {
5866 model: M;
@@ -60,34 +68,34 @@ export interface TypedAdapter<S extends DBSchema> { · 1 unmodified line
6068 limit?: number;
6169 sortBy?: { field: string; direction: "asc" | "desc" };
6270 offset?: number;
63− }) => Effect.Effect<readonly RowOutput<S, M>[], Error>;
71+ }) => Effect.Effect<readonly RowOutput<S, M>[], E>;
6472
6573 readonly update: <M extends keyof S & string>(data: {
6674 model: M;
6775 where: Where[];
6876 update: Partial<RowInput<S, M>>;
69− }) => Effect.Effect<RowOutput<S, M> | null, Error>;
77+ }) => Effect.Effect<RowOutput<S, M> | null, E>;
7078
7179 readonly updateMany: <M extends keyof S & string>(data: {
7280 model: M;
7381 where: Where[];
7482 update: Partial<RowInput<S, M>>;
75− }) => Effect.Effect<number, Error>;
83+ }) => Effect.Effect<number, E>;
7684
7785 readonly delete: <M extends keyof S & string>(data: {
7886 model: M;
7987 where: Where[];
80− }) => Effect.Effect<void, Error>;
88+ }) => Effect.Effect<void, E>;
8189
8290 readonly deleteMany: <M extends keyof S & string>(data: {
8391 model: M;
8492 where: Where[];
85− }) => Effect.Effect<number, Error>;
93+ }) => Effect.Effect<number, E>;
8694
8795 readonly count: <M extends keyof S & string>(data: {
8896 model: M;
8997 where?: Where[];
90− }) => Effect.Effect<number, Error>;
98+ }) => Effect.Effect<number, E>;
9199 }
92100
93101 /**
packages/core/storage-drizzle/src/adapter.ts+162−61
@@ -3,7 +3,7 @@ · 2 unmodified lines
33 //
44 // Vendored from better-auth (packages/drizzle-adapter/src/drizzle-adapter.ts)
55 // under MIT. Adapted for executor:
6−// - Promise/async → Effect.Effect<T, Error>
6+// - Promise/async → Effect.Effect<T, StorageFailure>
77 // - Tables read from `db._.fullSchema` (drizzle schema introspection)
88 // - Relational queries via `db.query[model]` for join resolution
99 // - Filter/compile logic matches our CleanedWhere shape directly
@@ -41,7 +41,16 @@ import type { · 31 unmodified lines
4141 DBSchema,
4242 JoinConfig,
4343 } from "@executor/storage-core";
44−import { createAdapter } from "@executor/storage-core";
44+import {
45+ StorageError,
46+ UniqueViolationError,
47+ createAdapter,
48+} from "@executor/storage-core";
49+
50+// Mirrors `StorageFailure` from @executor/storage-core/adapter — kept
51+// local so we don't force a new named export on the public index. Both
52+// constructors are already exported, so the union is reconstructible.
53+type StorageFailure = StorageError | UniqueViolationError;
4554
4655 // ---------------------------------------------------------------------------
4756 // Types
@@ -227,18 +236,54 @@ const buildIncludes = ( · 179 unmodified lines
227236
228237 // ---------------------------------------------------------------------------
229238 // Promise → Effect helper
239+//
240+// Classifies postgres driver errors: `23505` (unique_violation) maps to
241+// `UniqueViolationError`, everything else to `StorageError`. Sqlite
242+// raises a `SQLITE_CONSTRAINT_UNIQUE` via a synchronous throw; we detect
243+// that via the message since better-sqlite3's error code is on the
244+// object too.
230245 // ---------------------------------------------------------------------------
231246
247+const isUniqueViolation = (cause: unknown): boolean => {
248+ if (!cause || typeof cause !== "object") return false;
249+ const c = cause as { code?: unknown; message?: unknown };
250+ if (c.code === "23505") return true;
251+ if (typeof c.code === "string" && c.code === "SQLITE_CONSTRAINT_UNIQUE") {
252+ return true;
253+ }
254+ if (
255+ typeof c.message === "string" &&
256+ /unique constraint|UNIQUE constraint failed/i.test(c.message)
257+ ) {
258+ return true;
259+ }
260+ return false;
261+};
262+
263+const classifyError = (
264+ op: string,
265+ model: string | undefined,
266+ cause: unknown,
267+): StorageFailure => {
268+ if (isUniqueViolation(cause)) {
269+ return model !== undefined
270+ ? new UniqueViolationError({ model })
271+ : new UniqueViolationError({});
272+ }
273+ return new StorageError({
274+ message: `[storage-drizzle] ${op} failed: ${cause instanceof Error ? cause.message : String(cause)}`,
275+ cause,
276+ });
277+};
278+
232279 const runPromise = <T>(
233280 op: string,
234281 fn: () => Promise<T>,
235−): Effect.Effect<T, Error> =>
282+ model?: string,
283+): Effect.Effect<T, StorageFailure> =>
236284 Effect.tryPromise({
237285 try: fn,
238− catch: (e) =>
239− new Error(
240− `[storage-drizzle] ${op} failed: ${e instanceof Error ? e.message : String(e)}`,
241− ),
286+ catch: (cause) => classifyError(op, model, cause),
242287 });
243288
244289 // ---------------------------------------------------------------------------
@@ -254,33 +299,46 @@ const withReturning = ( · 9 unmodified lines
254299 // eslint-disable-next-line @typescript-eslint/no-explicit-any
255300 builder: any,
256301 data: Record<string, unknown>,
257−): Effect.Effect<Record<string, unknown>, Error> =>
302+ model: string,
303+): Effect.Effect<Record<string, unknown>, StorageFailure> =>
258304 Effect.gen(function* () {
259305 if (provider === "mysql") {
260− yield* runPromise("mysql insert execute", () => builder.execute());
306+ yield* runPromise("mysql insert execute", () => builder.execute(), model);
261307 // best-effort: look up by id if present
262308 if (data.id !== undefined) {
263− const rows = (yield* runPromise("mysql select after insert", () =>
264− db.select().from(table).where(eq(table.id, data.id)).limit(1),
309+ const rows = (yield* runPromise(
310+ "mysql select after insert",
311+ () => db.select().from(table).where(eq(table.id, data.id)).limit(1),
312+ model,
265313 )) as Record<string, unknown>[];
266314 if (!rows[0])
267315 return yield* Effect.fail(
268− new Error("[storage-drizzle] mysql insert: no row returned"),
316+ new StorageError({
317+ message: "[storage-drizzle] mysql insert: no row returned",
318+ cause: undefined,
319+ }),
269320 );
270321 return rows[0];
271322 }
272323 return yield* Effect.fail(
273− new Error(
274− "[storage-drizzle] mysql insert: id not provided, cannot recover row",
275− ),
324+ new StorageError({
325+ message:
326+ "[storage-drizzle] mysql insert: id not provided, cannot recover row",
327+ cause: undefined,
328+ }),
276329 );
277330 }
278− const rows = (yield* runPromise("insert returning", () =>
279− builder.returning(),
331+ const rows = (yield* runPromise(
332+ "insert returning",
333+ () => builder.returning(),
334+ model,
280335 )) as Record<string, unknown>[];
281336 if (!rows[0])
282337 return yield* Effect.fail(
283− new Error("[storage-drizzle] insert returned no rows"),
338+ new StorageError({
339+ message: "[storage-drizzle] insert returned no rows",
340+ cause: undefined,
341+ }),
284342 );
285343 return rows[0];
286344 });
@@ -308,7 +366,14 @@ export const drizzleAdapter = (options: DrizzleAdapterOptions): DBAdapter => { · 21 unmodified lines
308366 Effect.gen(function* () {
309367 const table = getTable(model);
310368 const builder = db.insert(table).values(data);
311− const row = yield* withReturning(db, provider, table, builder, data);
369+ const row = yield* withReturning(
370+ db,
371+ provider,
372+ table,
373+ builder,
374+ data,
375+ model,
376+ );
312377 return row as never;
313378 }),
314379
@@ -328,8 +393,10 @@ export const drizzleAdapter = (options: DrizzleAdapterOptions): DBAdapter => { · 13 unmodified lines
328393 const all: Record<string, unknown>[] = [];
329394 for (let i = 0; i < data.length; i += CHUNK) {
330395 const slice = data.slice(i, i + CHUNK) as Record<string, unknown>[];
331− const rows = (yield* runPromise("insert many returning", () =>
332− db.insert(table).values(slice).returning(),
396+ const rows = (yield* runPromise(
397+ "insert many returning",
398+ () => db.insert(table).values(slice).returning(),
399+ model,
333400 )) as Record<string, unknown>[];
334401 for (const row of rows) all.push(row);
335402 }
@@ -342,20 +409,25 @@ export const drizzleAdapter = (options: DrizzleAdapterOptions): DBAdapter => { · 6 unmodified lines
342409 const clause = compileWhere(table, where, provider);
343410 if (join && db.query && db.query[model]) {
344411 const includes = buildIncludes(join);
345− const rows = (yield* runPromise("findOne query.findFirst", () =>
346− Promise.resolve(
347− db.query[model].findFirst({
348− where: clause,
349− with: includes,
350− }),
351− ),
412+ const rows = (yield* runPromise(
413+ "findOne query.findFirst",
414+ () =>
415+ Promise.resolve(
416+ db.query[model].findFirst({
417+ where: clause,
418+ with: includes,
419+ }),
420+ ),
421+ model,
352422 )) as Record<string, unknown> | undefined;
353423 return (rows ?? null) as never;
354424 }
355425 let q = db.select().from(table);
356426 if (clause) q = q.where(clause);
357− const rows = (yield* runPromise("findOne select", () =>
358− q.limit(1),
427+ const rows = (yield* runPromise(
428+ "findOne select",
429+ () => q.limit(1),
430+ model,
359431 )) as Record<string, unknown>[];
360432 return (rows[0] ?? null) as never;
361433 }),
@@ -378,8 +450,10 @@ export const drizzleAdapter = (options: DrizzleAdapterOptions): DBAdapter => { · 16 unmodified lines
378450 const fn = sortBy.direction === "desc" ? desc : asc;
379451 opts.orderBy = [fn(col)];
380452 }
381− const rows = (yield* runPromise("findMany query.findMany", () =>
382− Promise.resolve(db.query[model].findMany(opts)),
453+ const rows = (yield* runPromise(
454+ "findMany query.findMany",
455+ () => Promise.resolve(db.query[model].findMany(opts)),
456+ model,
383457 )) as Record<string, unknown>[];
384458 return rows as never[];
385459 }
@@ -397,8 +471,10 @@ export const drizzleAdapter = (options: DrizzleAdapterOptions): DBAdapter => { · 11 unmodified lines
397471 else if (offset !== undefined && provider === "sqlite")
398472 q = q.limit(Number.MAX_SAFE_INTEGER);
399473 if (offset !== undefined) q = q.offset(offset);
400− const rows = (yield* runPromise("findMany select", () =>
401− Promise.resolve(q),
474+ const rows = (yield* runPromise(
475+ "findMany select",
476+ () => Promise.resolve(q),
477+ model,
402478 )) as Record<string, unknown>[];
403479 return rows as never[];
404480 }),
@@ -409,8 +485,10 @@ export const drizzleAdapter = (options: DrizzleAdapterOptions): DBAdapter => { · 4 unmodified lines
409485 const clause = compileWhere(table, where, provider);
410486 let q = db.select({ c: count() }).from(table);
411487 if (clause) q = q.where(clause);
412− const rows = (yield* runPromise("count select", () =>
413− Promise.resolve(q),
488+ const rows = (yield* runPromise(
489+ "count select",
490+ () => Promise.resolve(q),
491+ model,
414492 )) as { c: number | string | bigint }[];
415493 const raw = rows[0]?.c ?? 0;
416494 return typeof raw === "number" ? raw : Number(raw);
@@ -423,22 +501,32 @@ export const drizzleAdapter = (options: DrizzleAdapterOptions): DBAdapter => { · 6 unmodified lines
423501 // Find matching rows first — if >1, DBAdapter contract says null
424502 let findQ = db.select().from(table);
425503 if (clause) findQ = findQ.where(clause);
426− const matched = (yield* runPromise("update pre-select", () =>
427− findQ.limit(2),
504+ const matched = (yield* runPromise(
505+ "update pre-select",
506+ () => findQ.limit(2),
507+ model,
428508 )) as Record<string, unknown>[];
429509 if (matched.length === 0) return null;
430510 if (matched.length > 1) return null;
431511 const target = matched[0]!;
432512 let updQ = db.update(table).set(update).where(eq(table.id, target.id));
433513 if (provider !== "mysql") {
434− const rows = (yield* runPromise("update returning", () =>
435− updQ.returning(),
514+ const rows = (yield* runPromise(
515+ "update returning",
516+ () => updQ.returning(),
517+ model,
436518 )) as Record<string, unknown>[];
437519 return (rows[0] ?? null) as never;
438520 }
439− yield* runPromise("mysql update execute", () => updQ.execute());
440− const reread = (yield* runPromise("mysql update reread", () =>
441− db.select().from(table).where(eq(table.id, target.id)).limit(1),
521+ yield* runPromise(
522+ "mysql update execute",
523+ () => updQ.execute(),
524+ model,
525+ );
526+ const reread = (yield* runPromise(
527+ "mysql update reread",
528+ () => db.select().from(table).where(eq(table.id, target.id)).limit(1),
529+ model,
442530 )) as Record<string, unknown>[];
443531 return (reread[0] ?? null) as never;
444532 }),
@@ -451,14 +539,20 @@ export const drizzleAdapter = (options: DrizzleAdapterOptions): DBAdapter => { · 6 unmodified lines
451539 // but we don't want to rely on that in the generic path)
452540 let countQ = db.select({ c: count() }).from(table);
453541 if (clause) countQ = countQ.where(clause);
454− const rows = (yield* runPromise("updateMany count", () =>
455− Promise.resolve(countQ),
542+ const rows = (yield* runPromise(
543+ "updateMany count",
544+ () => Promise.resolve(countQ),
545+ model,
456546 )) as { c: number | string | bigint }[];
457547 const n = Number(rows[0]?.c ?? 0);
458548 if (n === 0) return 0;
459549 let updQ = db.update(table).set(update);
460550 if (clause) updQ = updQ.where(clause);
461− yield* runPromise("updateMany execute", () => Promise.resolve(updQ));
551+ yield* runPromise(
552+ "updateMany execute",
553+ () => Promise.resolve(updQ),
554+ model,
555+ );
462556 return n;
463557 }),
464558
@@ -469,13 +563,17 @@ export const drizzleAdapter = (options: DrizzleAdapterOptions): DBAdapter => { · 4 unmodified lines
469563 // Mirror in-memory semantics: delete first matching row only
470564 let findQ = db.select({ id: table.id }).from(table);
471565 if (clause) findQ = findQ.where(clause);
472− const matched = (yield* runPromise("delete pre-select", () =>
473− findQ.limit(1),
566+ const matched = (yield* runPromise(
567+ "delete pre-select",
568+ () => findQ.limit(1),
569+ model,
474570 )) as { id: unknown }[];
475571 const first = matched[0];
476572 if (!first) return;
477− yield* runPromise("delete exec", () =>
478− Promise.resolve(db.delete(table).where(eq(table.id, first.id))),
573+ yield* runPromise(
574+ "delete exec",
575+ () => Promise.resolve(db.delete(table).where(eq(table.id, first.id))),
576+ model,
479577 );
480578 }),
481579
@@ -485,14 +583,20 @@ export const drizzleAdapter = (options: DrizzleAdapterOptions): DBAdapter => { · 3 unmodified lines
485583 const clause = compileWhere(table, where, provider);
486584 let countQ = db.select({ c: count() }).from(table);
487585 if (clause) countQ = countQ.where(clause);
488− const rows = (yield* runPromise("deleteMany count", () =>
489− Promise.resolve(countQ),
586+ const rows = (yield* runPromise(
587+ "deleteMany count",
588+ () => Promise.resolve(countQ),
589+ model,
490590 )) as { c: number | string | bigint }[];
491591 const n = Number(rows[0]?.c ?? 0);
492592 if (n === 0) return 0;
493593 let delQ = db.delete(table);
494594 if (clause) delQ = delQ.where(clause);
495− yield* runPromise("deleteMany exec", () => Promise.resolve(delQ));
595+ yield* runPromise(
596+ "deleteMany exec",
597+ () => Promise.resolve(delQ),
598+ model,
599+ );
496600 return n;
497601 }),
498602 };
@@ -553,9 +657,9 @@ export const drizzleAdapter = (options: DrizzleAdapterOptions): DBAdapter => { · 54 unmodified lines
553657 }).pipe(
554658 Effect.mapError((e) => {
555659 if (e instanceof TxFailure) return e.inner;
556− return e instanceof Error ? e : new Error(String(e));
660+ return classifyError("pg transaction", undefined, e);
557661 }),
558− ) as Effect.Effect<R, E | Error>;
662+ ) as Effect.Effect<R, E | StorageFailure>;
559663 }
560664
561665 return Effect.gen(function* () {
@@ -578,16 +682,13 @@ export const drizzleAdapter = (options: DrizzleAdapterOptions): DBAdapter => { · 16 unmodified lines
578682 }
579683 return res;
580684 },
581− catch: (e) =>
582− new Error(
583− `[storage-drizzle] ${stmt} failed: ${e instanceof Error ? e.message : String(e)}`,
584− ),
685+ catch: (cause) => classifyError(stmt, undefined, cause),
585686 });
586687 const maybePromise = yield* runStmt("BEGIN");
587688 if (maybePromise && typeof (maybePromise as { then?: unknown }).then === "function") {
588689 yield* Effect.tryPromise({
589690 try: () => maybePromise as Promise<unknown>,
590− catch: (e) => new Error(String(e)),
691+ catch: (cause) => classifyError("BEGIN", undefined, cause),
591692 });
592693 }
593694 const nested = drizzleAdapter({
@@ -606,7 +707,7 @@ export const drizzleAdapter = (options: DrizzleAdapterOptions): DBAdapter => { · 12 unmodified lines
606707 if (commitRes && typeof (commitRes as { then?: unknown }).then === "function") {
607708 yield* Effect.tryPromise({
608709 try: () => commitRes as Promise<unknown>,
609− catch: (e) => new Error(String(e)),
710+ catch: (cause) => classifyError("COMMIT", undefined, cause),
610711 });
611712 }
612713 return result;
packages/core/storage-file/src/blob-store.ts+7−3
@@ -13,6 +13,7 @@ import { and, eq, sql as drizzleSql } from "drizzle-orm"; · 12 unmodified lines
1313 import { primaryKey, sqliteTable, text } from "drizzle-orm/sqlite-core";
1414
1515 import type { BlobStore } from "@executor/sdk";
16+import { StorageError } from "@executor/storage-core";
1617
1718 export const blobTable = sqliteTable(
1819 "blob",
@@ -34,9 +35,12 @@ export interface MakeSqliteBlobStoreOptions { · 15 unmodified lines
3435
3536 const wrapErr =
3637 (op: string) =>
37− (e: unknown): Error => {
38− const msg = e instanceof Error ? e.message : String(e);
39− return new Error(`[storage-file] blob ${op}: ${msg}`);
38+ (cause: unknown): StorageError => {
39+ const msg = cause instanceof Error ? cause.message : String(cause);
40+ return new StorageError({
41+ message: `[storage-file] blob ${op}: ${msg}`,
42+ cause,
43+ });
4044 };
4145
4246 export const makeSqliteBlobStore = (
packages/core/storage-postgres/src/blob-store.ts+7−3
@@ -15,6 +15,7 @@ import type { PgDatabase } from "drizzle-orm/pg-core"; · 14 unmodified lines
1515 import { pgTable, primaryKey, text } from "drizzle-orm/pg-core";
1616
1717 import type { BlobStore } from "@executor/sdk";
18+import { StorageError } from "@executor/storage-core";
1819
1920 export const blobTable = pgTable(
2021 "blob",
@@ -35,9 +36,12 @@ export interface MakePostgresBlobStoreOptions { · 14 unmodified lines
3536
3637 const wrapErr =
3738 (op: string) =>
38− (e: unknown): Error => {
39− const msg = e instanceof Error ? e.message : String(e);
40− return new Error(`[storage-postgres] blob ${op}: ${msg}`);
39+ (cause: unknown): StorageError => {
40+ const msg = cause instanceof Error ? cause.message : String(cause);
41+ return new StorageError({
42+ message: `[storage-postgres] blob ${op}: ${msg}`,
43+ cause,
44+ });
4145 };
4246
4347 export const makePostgresBlobStore = (
packages/plugins/file-secrets/src/index.ts+57−22
@@ -2,7 +2,12 @@ import { Effect, Schema } from "effect"; · 1 unmodified line
22 import * as fs from "node:fs";
33 import * as path from "node:path";
44
5−import { definePlugin, type PluginCtx, type SecretProvider } from "@executor/sdk";
5+import {
6+ definePlugin,
7+ StorageError,
8+ type PluginCtx,
9+ type SecretProvider,
10+} from "@executor/sdk";
611
712 // ---------------------------------------------------------------------------
813 // XDG data dir resolution
@@ -43,16 +48,28 @@ const decodeScopedAuthFile = Schema.decodeUnknownSync(ScopedAuthFile); · 34 unmodified lines
4348
4449 // ---------------------------------------------------------------------------
4550 // File I/O with restricted permissions
51+//
52+// These helpers throw on real I/O or decode failures — the provider wraps
53+// every call in `Effect.try` so those throws surface as typed
54+// `StorageError` on the Effect error channel. Previously `readFullFile`
55+// used a blanket `try { ... } catch { return {}; }` which masked JSON
56+// parse errors, schema decode failures, and permission errors as
57+// "empty file", making misconfigured installs silently return null from
58+// every `get`.
4659 // ---------------------------------------------------------------------------
4760
4861 const readFullFile = (filePath: string): Record<string, Record<string, string>> => {
62+ if (!fs.existsSync(filePath)) return {};
63+ let raw: string;
4964 try {
50− if (!fs.existsSync(filePath)) return {};
51− const raw = fs.readFileSync(filePath, "utf-8");
52− return decodeScopedAuthFile(JSON.parse(raw));
53− } catch {
54− return {};
65+ raw = fs.readFileSync(filePath, "utf-8");
66+ } catch (cause) {
67+ // Treat "file disappeared between existsSync and readFileSync" as
68+ // absence — anything else (EACCES, EISDIR, …) propagates.
69+ if ((cause as NodeJS.ErrnoException).code === "ENOENT") return {};
70+ throw cause;
5571 }
72+ return decodeScopedAuthFile(JSON.parse(raw));
5673 };
5774
5875 const readScopeSecrets = (filePath: string, scopeId: string): Record<string, string> =>
@@ -100,36 +117,54 @@ export interface FileSecretsExtension { · 41 unmodified lines
100117 // Provider factory (internal)
101118 // ---------------------------------------------------------------------------
102119
120+const toStorageError = (cause: unknown) =>
121+ new StorageError({
122+ message: cause instanceof Error ? cause.message : String(cause),
123+ cause,
124+ });
125+
103126 const makeScopedProvider = (filePath: string, scopeId: string): SecretProvider => ({
104127 key: "file",
105128 writable: true,
106129
107130 get: (secretId) =>
108− Effect.sync(() => {
109− const data = readScopeSecrets(filePath, scopeId);
110− return data[secretId] ?? null;
131+ Effect.try({
132+ try: () => {
133+ const data = readScopeSecrets(filePath, scopeId);
134+ return data[secretId] ?? null;
135+ },
136+ catch: toStorageError,
111137 }),
112138
113139 set: (secretId, value) =>
114− Effect.sync(() => {
115− const data = readScopeSecrets(filePath, scopeId);
116− data[secretId] = value;
117− writeScopeSecrets(filePath, scopeId, data);
140+ Effect.try({
141+ try: () => {
142+ const data = readScopeSecrets(filePath, scopeId);
143+ data[secretId] = value;
144+ writeScopeSecrets(filePath, scopeId, data);
145+ },
146+ catch: toStorageError,
118147 }),
119148
120149 delete: (secretId) =>
121− Effect.sync(() => {
122− const data = readScopeSecrets(filePath, scopeId);
123− const had = secretId in data;
124− delete data[secretId];
125− if (had) writeScopeSecrets(filePath, scopeId, data);
126− return had;
150+ Effect.try({
151+ try: () => {
152+ const data = readScopeSecrets(filePath, scopeId);
153+ const had = secretId in data;
154+ delete data[secretId];
155+ if (had) writeScopeSecrets(filePath, scopeId, data);
156+ return had;
157+ },
158+ catch: toStorageError,
127159 }),
128160
129161 list: () =>
130− Effect.sync(() => {
131− const data = readScopeSecrets(filePath, scopeId);
132− return Object.keys(data).map((k) => ({ id: k, name: k }));
162+ Effect.try({
163+ try: () => {
164+ const data = readScopeSecrets(filePath, scopeId);
165+ return Object.keys(data).map((k) => ({ id: k, name: k }));
166+ },
167+ catch: toStorageError,
133168 }),
134169 });
135170
packages/plugins/google-discovery/src/api/group.ts+38−26
@@ -1,7 +1,13 @@
11 import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "@effect/platform";
22 import { Schema } from "effect";
33 import { ScopeId } from "@executor/sdk";
4+import { InternalError } from "@executor/api";
45
6+import {
7+ GoogleDiscoveryOAuthError,
8+ GoogleDiscoveryParseError,
9+ GoogleDiscoverySourceError,
10+} from "../sdk/errors";
511 import { GoogleDiscoveryStoredSourceSchema } from "../sdk/stored-source";
612
713 export { HttpApiSchema };
@@ -109,55 +115,61 @@ export class GoogleDiscoveryApiError extends Schema.TaggedError<GoogleDiscoveryA · 101 unmodified lines
109115 HttpApiSchema.annotations({ status: 400 }),
110116 ) {}
111117
112−export class GoogleDiscoveryInternalError extends Schema.TaggedError<GoogleDiscoveryInternalError>()(
113− "GoogleDiscoveryInternalError",
114− {
115− message: Schema.String,
116− },
117− HttpApiSchema.annotations({ status: 500 }),
118−) {}
118+// ---------------------------------------------------------------------------
119+// Group
120+//
121+// Domain errors + the shared opaque 500 (`InternalError`) are declared
122+// once at the group level via `.addError(...)` — every endpoint
123+// inherits them. The domain error carries its HTTP status via
124+// `HttpApiSchema.annotations`; `InternalError` is the public 5xx
125+// surface, translated from `StorageError` at the HTTP edge by
126+// `withCapture`. No per-endpoint `.addError(...)`, no per-handler
127+// InternalError — handlers just `return yield* ext.foo(...)`.
128+// ---------------------------------------------------------------------------
119129
120130 export class GoogleDiscoveryGroup extends HttpApiGroup.make("googleDiscovery")
121131 .add(
122132 HttpApiEndpoint.post("probeDiscovery")`/scopes/${scopeIdParam}/google-discovery/probe`
123133 .setPayload(ProbePayload)
124− .addSuccess(ProbeResponse)
125− .addError(GoogleDiscoveryApiError)
126− .addError(GoogleDiscoveryInternalError),
134+ .addSuccess(ProbeResponse),
127135 )
128136 .add(
129137 HttpApiEndpoint.post("addSource")`/scopes/${scopeIdParam}/google-discovery/sources`
130138 .setPayload(AddSourcePayload)
131− .addSuccess(AddSourceResponse)
132− .addError(GoogleDiscoveryApiError)
133− .addError(GoogleDiscoveryInternalError),
139+ .addSuccess(AddSourceResponse),
134140 )
135141 .add(
136142 HttpApiEndpoint.post("startOAuth")`/scopes/${scopeIdParam}/google-discovery/oauth/start`
137143 .setPayload(StartOAuthPayload)
138− .addSuccess(StartOAuthResponse)
139− .addError(GoogleDiscoveryApiError)
140− .addError(GoogleDiscoveryInternalError),
144+ .addSuccess(StartOAuthResponse),
141145 )
142146 .add(
143147 HttpApiEndpoint.post("completeOAuth")`/scopes/${scopeIdParam}/google-discovery/oauth/complete`
144148 .setPayload(CompleteOAuthPayload)
145− .addSuccess(CompleteOAuthResponse)
146− .addError(GoogleDiscoveryApiError)
147− .addError(GoogleDiscoveryInternalError),
149+ .addSuccess(CompleteOAuthResponse),
148150 )
149151 .add(
150152 HttpApiEndpoint.get("oauthCallback")`/google-discovery/oauth/callback`
151153 .setUrlParams(OAuthCallbackParams)
152− .addSuccess(HtmlResponse)
153− .addError(GoogleDiscoveryApiError)
154− .addError(GoogleDiscoveryInternalError),
154+ .addSuccess(HtmlResponse),
155155 )
156156 .add(
157157 HttpApiEndpoint.get(
158158 "getSource",
159159 )`/scopes/${scopeIdParam}/google-discovery/sources/${namespaceParam}`
160− .addSuccess(Schema.NullOr(GoogleDiscoveryStoredSourceSchema))
161− .addError(GoogleDiscoveryApiError)
162− .addError(GoogleDiscoveryInternalError),
163− ) {}
160+ .addSuccess(Schema.NullOr(GoogleDiscoveryStoredSourceSchema)),
161+ )
162+ // Errors declared once at the group level — every endpoint inherits.
163+ // `InternalError` is the shared opaque 500 translated at the HTTP edge
164+ // by `withCapture`. The others are 4xx domain errors carrying their
165+ // status via `HttpApiSchema.annotations`; handlers return them through
166+ // the typed channel and HttpApi encodes them directly. We only list
167+ // errors a Google Discovery *group* endpoint can surface —
168+ // `GoogleDiscoveryInvocationError` is thrown inside `invokeTool` which
169+ // is reached via the core `tools.invoke` endpoint, not any Google
170+ // Discovery-group endpoint, so it doesn't belong here.
171+ .addError(InternalError)
172+ .addError(GoogleDiscoveryApiError)
173+ .addError(GoogleDiscoveryOAuthError)
174+ .addError(GoogleDiscoveryParseError)
175+ .addError(GoogleDiscoverySourceError) {}
packages/plugins/google-discovery/src/api/handlers.test.ts+61−42
@@ -1,8 +1,17 @@
1+// ---------------------------------------------------------------------------
2+// Handler-level integration test for the Google Discovery group.
3+//
4+// Verifies the layer wiring stays coherent end-to-end: the handlers
5+// pull the wrapped extension from the service, and any un-caught cause
6+// lands in the observability middleware — producing a 500 whose body is
7+// the opaque `InternalError` schema (no internal leakage).
8+// ---------------------------------------------------------------------------
9+
110 import { HttpApiBuilder, HttpServer } from "@effect/platform";
2−import { describe, expect, it } from "vitest";
11+import { describe, expect, it } from "@effect/vitest";
312 import { Effect, Layer } from "effect";
413
5−import { addGroup } from "@executor/api";
14+import { addGroup, observabilityMiddleware } from "@executor/api";
615 import { CoreHandlers, ExecutionEngineService, ExecutorService } from "@executor/api/server";
716 import type { GoogleDiscoveryPluginExtension } from "../sdk/plugin";
817 import { GoogleDiscoveryExtensionService, GoogleDiscoveryHandlers } from "./handlers";
@@ -10,57 +19,67 @@ import { GoogleDiscoveryGroup } from "./group"; · 1 unmodified line
1019
1120 const unused = Effect.dieMessage("unused");
1221
13−const createFailingExtension = (): GoogleDiscoveryPluginExtension => ({
22+const failingExtension: GoogleDiscoveryPluginExtension = {
1423 probeDiscovery: () => Effect.die(new Error("Not implemented")),
1524 addSource: () => unused,
1625 removeSource: () => unused,
1726 startOAuth: () => unused,
1827 completeOAuth: () => Effect.die(new Error("Not implemented")),
1928 getSource: () => Effect.succeed(null),
20−});
29+};
2130
2231 const Api = addGroup(GoogleDiscoveryGroup);
2332
24−const fakeExecutor = {} as any;
25−const fakeExecutionEngine = {} as any;
26−
27−const createHandler = () =>
28− HttpApiBuilder.toWebHandler(
29− HttpApiBuilder.api(Api).pipe(
30− Layer.provide(CoreHandlers),
31− Layer.provide(GoogleDiscoveryHandlers),
32− Layer.provide(Layer.succeed(ExecutorService, fakeExecutor)),
33− Layer.provide(Layer.succeed(ExecutionEngineService, fakeExecutionEngine)),
34− Layer.provide(Layer.succeed(GoogleDiscoveryExtensionService, createFailingExtension())),
35− Layer.provideMerge(HttpServer.layerContext),
36− Layer.provideMerge(HttpApiBuilder.Router.Live),
37− Layer.provideMerge(HttpApiBuilder.Middleware.layer),
33+// `acquireRelease` keeps disposal inside the Effect scope — no
34+// try/finally, no per-test cleanup plumbing. `it.scoped` closes the
35+// scope for us.
36+const WebHandler = Effect.acquireRelease(
37+ Effect.sync(() =>
38+ HttpApiBuilder.toWebHandler(
39+ HttpApiBuilder.api(Api).pipe(
40+ Layer.provide(CoreHandlers),
41+ Layer.provide(GoogleDiscoveryHandlers),
42+ Layer.provide(observabilityMiddleware(Api)),
43+ Layer.provide(Layer.succeed(ExecutorService, {} as never)),
44+ Layer.provide(Layer.succeed(ExecutionEngineService, {} as never)),
45+ Layer.provide(
46+ Layer.succeed(GoogleDiscoveryExtensionService, failingExtension),
47+ ),
48+ Layer.provideMerge(HttpServer.layerContext),
49+ Layer.provideMerge(HttpApiBuilder.Router.Live),
50+ Layer.provideMerge(HttpApiBuilder.Middleware.layer),
51+ ),
3852 ),
39− );
53+ ),
54+ (web) => Effect.promise(() => web.dispose()),
55+);
4056
4157 describe("GoogleDiscoveryHandlers", () => {
42− it("sanitizes unknown endpoint failures", async () => {
43− const web = createHandler();
44− try {
45− const response = await web.handler(
46− new Request("http://localhost/scopes/scope_1/google-discovery/probe", {
47− method: "POST",
48− headers: { "content-type": "application/json" },
49− body: JSON.stringify({
50− discoveryUrl: "https://example.googleapis.com/$discovery/rest?version=v1",
51− }),
52− }),
53− );
58+ it.scoped(
59+ "defect-returning methods produce an opaque InternalError, no leakage",
60+ () =>
61+ Effect.gen(function* () {
62+ const web = yield* WebHandler;
63+ const response = yield* Effect.promise(() =>
64+ web.handler(
65+ new Request("http://localhost/scopes/scope_1/google-discovery/probe", {
66+ method: "POST",
67+ headers: { "content-type": "application/json" },
68+ body: JSON.stringify({
69+ discoveryUrl: "https://example.googleapis.com/$discovery/rest?version=v1",
70+ }),
71+ }),
72+ ),
73+ );
5474
55− expect(response.status).toBe(500);
56− const body = await response.json();
57− expect(body).toEqual({
58− _tag: "GoogleDiscoveryInternalError",
59− message: "Internal server error",
60− });
61− expect(JSON.stringify(body)).not.toContain("Not implemented");
62− } finally {
63− await web.dispose();
64− }
65− });
75+ expect(response.status).toBe(500);
76+ const body = (yield* Effect.promise(() => response.json())) as {
77+ _tag?: string;
78+ traceId?: string;
79+ };
80+ expect(body._tag).toBe("InternalError");
81+ expect(typeof body.traceId).toBe("string");
82+ expect(JSON.stringify(body)).not.toContain("Not implemented");
83+ }),
84+ );
6685 });
packages/plugins/google-discovery/src/api/handlers.ts+48−55
@@ -1,65 +1,58 @@
11 import { HttpApiBuilder, HttpServerResponse } from "@effect/platform";
2−import { Cause, Context, Effect } from "effect";
2+import { Context, Effect } from "effect";
33
44 import { runOAuthCallback } from "@executor/plugin-oauth2/http";
55
6−import { addGroup } from "@executor/api";
6+import { addGroup, capture } from "@executor/api";
77 import type {
88 GoogleDiscoveryAddSourceInput,
99 GoogleDiscoveryOAuthAuthResult,
1010 GoogleDiscoveryPluginExtension,
1111 } from "../sdk/plugin";
12−import {
13− GoogleDiscoveryInvocationError,
14− GoogleDiscoveryOAuthError,
15− GoogleDiscoveryParseError,
16− GoogleDiscoverySourceError,
17−} from "../sdk/errors";
18−import {
19− GoogleDiscoveryApiError,
20− GoogleDiscoveryGroup,
21− GoogleDiscoveryInternalError,
22−} from "./group";
12+import { GoogleDiscoveryOAuthError } from "../sdk/errors";
13+import { GoogleDiscoveryGroup } from "./group";
14+
15+// ---------------------------------------------------------------------------
16+// Service tag
17+//
18+// Holds the `Captured` shape — every method's `StorageFailure` channel
19+// has been swapped for `InternalError({ traceId })`. The host app
20+// provides an already-wrapped extension via
21+// `Layer.succeed(GoogleDiscoveryExtensionService, withCapture(executor.googleDiscovery))`.
22+// Handlers see `InternalError` in the error union, which matches
23+// `.addError(InternalError)` on the group — no per-handler translation.
24+// ---------------------------------------------------------------------------
2325
2426 export class GoogleDiscoveryExtensionService extends Context.Tag("GoogleDiscoveryExtensionService")<
2527 GoogleDiscoveryExtensionService,
2628 GoogleDiscoveryPluginExtension
2729 >() {}
2830
31+// ---------------------------------------------------------------------------
32+// Composed API
33+// ---------------------------------------------------------------------------
34+
2935 const ExecutorApiWithGoogleDiscovery = addGroup(GoogleDiscoveryGroup);
3036
3137 const GOOGLE_DISCOVERY_OAUTH_CHANNEL = "executor:google-discovery-oauth-result";
3238
33−const toPopupErrorMessage = (error: unknown): string => {
34− if (error instanceof GoogleDiscoveryOAuthError) {
35− return error.message;
36− }
37− return "Authentication failed";
38−};
39−
40−const toGoogleDiscoveryApiError = (
41− error: unknown,
42−): GoogleDiscoveryApiError | GoogleDiscoveryInternalError => {
43− if (error instanceof GoogleDiscoveryApiError || error instanceof GoogleDiscoveryInternalError) {
44− return error;
45− }
46− if (
47− error instanceof GoogleDiscoveryParseError ||
48− error instanceof GoogleDiscoverySourceError ||
49− error instanceof GoogleDiscoveryOAuthError ||
50− error instanceof GoogleDiscoveryInvocationError
51− ) {
52− return new GoogleDiscoveryApiError({ message: error.message });
53− }
54− return new GoogleDiscoveryInternalError({ message: "Internal server error" });
55−};
39+const toPopupErrorMessage = (error: unknown): string =>
40+ error instanceof GoogleDiscoveryOAuthError ? error.message : "Authentication failed";
5641
57−const sanitizeGoogleDiscoveryFailure = <A, R>(
58− effect: Effect.Effect<A, unknown, R>,
59−): Effect.Effect<A, GoogleDiscoveryApiError | GoogleDiscoveryInternalError, R> =>
60− Effect.catchAllCause(effect, (cause) =>
61− Effect.fail(toGoogleDiscoveryApiError(Cause.squash(cause))),
62− );
42+// ---------------------------------------------------------------------------
43+// Handlers
44+//
45+// Each handler is exactly: yield the extension service, call the method,
46+// return. Plugin SDK errors flow through the typed channel and are
47+// schema-encoded to 4xx by HttpApi (see group.ts `.addError(...)` calls).
48+// `StorageFailure` has already been translated to `InternalError` by
49+// `withCapture` on the service instance; defects bubble up and are
50+// captured + downgraded to `InternalError(traceId)` by the API-level
51+// observability middleware.
52+//
53+// No `sanitize*`, no `liftDomainErrors`, no per-handler error mapping.
54+// If you find yourself adding error-handling here you're in the wrong layer.
55+// ---------------------------------------------------------------------------
6356
6457 export const GoogleDiscoveryHandlers = HttpApiBuilder.group(
6558 ExecutorApiWithGoogleDiscovery,
@@ -67,19 +60,19 @@ export const GoogleDiscoveryHandlers = HttpApiBuilder.group( · 1 unmodified line
6760 (handlers) =>
6861 handlers
6962 .handle("probeDiscovery", ({ payload }) =>
70− Effect.gen(function* () {
63+ capture(Effect.gen(function* () {
7164 const ext = yield* GoogleDiscoveryExtensionService;
7265 return yield* ext.probeDiscovery(payload.discoveryUrl);
73− }).pipe(sanitizeGoogleDiscoveryFailure),
66+ })),
7467 )
7568 .handle("addSource", ({ payload }) =>
76− Effect.gen(function* () {
69+ capture(Effect.gen(function* () {
7770 const ext = yield* GoogleDiscoveryExtensionService;
7871 return yield* ext.addSource(payload as GoogleDiscoveryAddSourceInput);
79− }).pipe(sanitizeGoogleDiscoveryFailure),
72+ })),
8073 )
8174 .handle("startOAuth", ({ payload }) =>
82− Effect.gen(function* () {
75+ capture(Effect.gen(function* () {
8376 const ext = yield* GoogleDiscoveryExtensionService;
8477 return yield* ext.startOAuth({
8578 name: payload.name,
@@ -89,30 +82,30 @@ export const GoogleDiscoveryHandlers = HttpApiBuilder.group( · 3 unmodified lines
8982 redirectUrl: payload.redirectUrl,
9083 scopes: payload.scopes,
9184 });
92− }).pipe(sanitizeGoogleDiscoveryFailure),
85+ })),
9386 )
9487 .handle("completeOAuth", ({ payload }) =>
95− Effect.gen(function* () {
88+ capture(Effect.gen(function* () {
9689 const ext = yield* GoogleDiscoveryExtensionService;
9790 return yield* ext.completeOAuth({
9891 state: payload.state,
9992 code: payload.code,
10093 error: payload.error,
10194 });
102− }).pipe(sanitizeGoogleDiscoveryFailure),
95+ })),
10396 )
10497 .handle("getSource", ({ path }) =>
105− Effect.gen(function* () {
98+ capture(Effect.gen(function* () {
10699 const ext = yield* GoogleDiscoveryExtensionService;
107100 return yield* ext.getSource(path.namespace);
108− }).pipe(sanitizeGoogleDiscoveryFailure),
101+ })),
109102 )
110103 .handle("oauthCallback", ({ urlParams }) =>
111− Effect.gen(function* () {
104+ capture(Effect.gen(function* () {
112105 const ext = yield* GoogleDiscoveryExtensionService;
113106 const html = yield* runOAuthCallback<
114107 GoogleDiscoveryOAuthAuthResult,
115− GoogleDiscoveryOAuthError,
108+ unknown,
116109 never
117110 >({
118111 complete: ({ state, code, error }) =>
@@ -126,6 +119,6 @@ export const GoogleDiscoveryHandlers = HttpApiBuilder.group( · 7 unmodified lines
126119 channelName: GOOGLE_DISCOVERY_OAUTH_CHANNEL,
127120 });
128121 return yield* HttpServerResponse.html(html);
129− }).pipe(sanitizeGoogleDiscoveryFailure),
122+ })),
130123 ),
131124 );
packages/plugins/google-discovery/src/sdk/binding-store.ts+22−12
@@ -14,7 +14,11 @@ · 13 unmodified lines
1414
1515 import { Effect, Schema } from "effect";
1616
17−import { defineSchema, type StorageDeps } from "@executor/sdk";
17+import {
18+ defineSchema,
19+ type StorageDeps,
20+ type StorageFailure,
21+} from "@executor/sdk";
1822
1923 import {
2024 GoogleDiscoveryMethodBinding,
@@ -101,43 +105,49 @@ const decodeJson = (value: unknown): unknown => { · 80 unmodified lines
101105 // Store interface
102106 // ---------------------------------------------------------------------------
103107
108+// Every method routes through the typed adapter (`ctx.storage.adapter`)
109+// so the typed error channel is `StorageFailure`. Schema-decode failures
110+// inside `Effect.gen` land as defects, not typed errors, and are caught
111+// by the HTTP edge's observability middleware.
104112 export interface GoogleDiscoveryStore {
105113 readonly getBinding: (toolId: string) => Effect.Effect<
106114 { readonly namespace: string; readonly binding: GoogleDiscoveryMethodBinding } | null,
107− Error
115+ StorageFailure
108116 >;
109117 readonly putBinding: (
110118 toolId: string,
111119 sourceId: string,
112120 binding: GoogleDiscoveryMethodBinding,
113− ) => Effect.Effect<void, Error>;
121+ ) => Effect.Effect<void, StorageFailure>;
114122 readonly removeBindingsBySource: (
115123 sourceId: string,
116− ) => Effect.Effect<readonly string[], Error>;
124+ ) => Effect.Effect<readonly string[], StorageFailure>;
117125 readonly getBindingsForSource: (
118126 sourceId: string,
119127 ) => Effect.Effect<
120128 ReadonlyMap<string, GoogleDiscoveryMethodBinding>,
121− Error
129+ StorageFailure
122130 >;
123131
124− readonly putSource: (source: GoogleDiscoveryStoredSource) => Effect.Effect<void, Error>;
125− readonly removeSource: (sourceId: string) => Effect.Effect<void, Error>;
132+ readonly putSource: (
133+ source: GoogleDiscoveryStoredSource,
134+ ) => Effect.Effect<void, StorageFailure>;
135+ readonly removeSource: (sourceId: string) => Effect.Effect<void, StorageFailure>;
126136 readonly getSource: (
127137 sourceId: string,
128− ) => Effect.Effect<GoogleDiscoveryStoredSource | null, Error>;
138+ ) => Effect.Effect<GoogleDiscoveryStoredSource | null, StorageFailure>;
129139 readonly getSourceConfig: (
130140 sourceId: string,
131− ) => Effect.Effect<GoogleDiscoveryStoredSourceData | null, Error>;
141+ ) => Effect.Effect<GoogleDiscoveryStoredSourceData | null, StorageFailure>;
132142
133143 readonly putOAuthSession: (
134144 sessionId: string,
135145 session: GoogleDiscoveryOAuthSession,
136− ) => Effect.Effect<void, Error>;
146+ ) => Effect.Effect<void, StorageFailure>;
137147 readonly getOAuthSession: (
138148 sessionId: string,
139− ) => Effect.Effect<GoogleDiscoveryOAuthSession | null, Error>;
140− readonly deleteOAuthSession: (sessionId: string) => Effect.Effect<void, Error>;
149+ ) => Effect.Effect<GoogleDiscoveryOAuthSession | null, StorageFailure>;
150+ readonly deleteOAuthSession: (sessionId: string) => Effect.Effect<void, StorageFailure>;
141151 }
142152
143153 // ---------------------------------------------------------------------------
packages/plugins/google-discovery/src/sdk/document.ts+6−2
@@ -130,8 +130,12 @@ const DiscoveryDocumentModel = Schema.Struct({ · 129 unmodified lines
130130 });
131131 type DiscoveryDocument = typeof DiscoveryDocumentModel.Type;
132132
133−const toParseError = (message: string, cause: unknown) =>
134− new GoogleDiscoveryParseError({ message, cause });
133+// The Schema.TaggedError version of GoogleDiscoveryParseError no longer
134+// carries a `cause` field — it would leak raw decoder internals over the
135+// wire. The decoder failure still shows up on the Effect cause chain for
136+// server-side logging; the client only sees the user-facing `message`.
137+const toParseError = (message: string, _cause: unknown) =>
138+ new GoogleDiscoveryParseError({ message });
135139
136140 const decodeUnknownWith =
137141 <A>(
packages/plugins/google-discovery/src/sdk/errors.ts+21−4
@@ -1,10 +1,25 @@
1+// Google Discovery plugin tagged errors. The three errors that cross
2+// the HTTP edge carry an `HttpApiSchema` annotation so they can be
3+// `.addError(...)` directly on the API group — handlers return them
4+// and HttpApi encodes each as a 4xx response with a typed body, no
5+// per-handler sanitisation step.
6+//
7+// `GoogleDiscoveryInvocationError` stays a `Data.TaggedError` because
8+// it only surfaces through `invokeTool`, which runs under the core
9+// `tools.invoke` endpoint — not any endpoint on the Google Discovery
10+// group — so it doesn't need an HTTP annotation.
11+
112 import { Data, Schema } from "effect";
13+import { HttpApiSchema } from "@effect/platform";
214 import type { Option } from "effect";
315
4−export class GoogleDiscoveryParseError extends Data.TaggedError("GoogleDiscoveryParseError")<{
5− readonly message: string;
6− readonly cause?: unknown;
7−}> {}
16+export class GoogleDiscoveryParseError extends Schema.TaggedError<GoogleDiscoveryParseError>()(
17+ "GoogleDiscoveryParseError",
18+ {
19+ message: Schema.String,
20+ },
21+ HttpApiSchema.annotations({ status: 400 }),
22+) {}
823
924 export class GoogleDiscoveryInvocationError extends Data.TaggedError(
1025 "GoogleDiscoveryInvocationError",
@@ -19,6 +34,7 @@ export class GoogleDiscoveryOAuthError extends Schema.TaggedError<GoogleDiscover · 8 unmodified lines
1934 {
2035 message: Schema.String,
2136 },
37+ HttpApiSchema.annotations({ status: 400 }),
2238 ) {}
2339
2440 export class GoogleDiscoverySourceError extends Schema.TaggedError<GoogleDiscoverySourceError>()(
@@ -26,4 +42,5 @@ export class GoogleDiscoverySourceError extends Schema.TaggedError<GoogleDiscove · 1 unmodified line
2642 {
2743 message: Schema.String,
2844 },
45+ HttpApiSchema.annotations({ status: 400 }),
2946 ) {}
packages/plugins/google-discovery/src/sdk/invoke.ts+37−13
@@ -6,12 +6,15 @@ import { · 5 unmodified lines
66 type OAuth2SecretsIO,
77 } from "@executor/plugin-oauth2";
88
9−import type { PluginCtx } from "@executor/sdk";
9+import type { PluginCtx, StorageFailure } from "@executor/sdk";
1010 import { SetSecretInput } from "@executor/sdk";
1111
1212 import { GOOGLE_TOKEN_URL } from "./oauth";
1313
14−import { GoogleDiscoveryInvocationError } from "./errors";
14+import {
15+ GoogleDiscoveryInvocationError,
16+ GoogleDiscoveryOAuthError,
17+} from "./errors";
1518 import type { GoogleDiscoveryStore } from "./binding-store";
1619 import {
1720 GoogleDiscoveryInvocationResult,
@@ -115,7 +118,7 @@ const resolveOAuthAccessToken = (input: { · 97 unmodified lines
115118 ctx: PluginCtx<GoogleDiscoveryStore>;
116119 sourceId: string;
117120 source: GoogleDiscoveryStoredSourceData;
118−}): Effect.Effect<string, Error> =>
121+}): Effect.Effect<string, GoogleDiscoveryOAuthError> =>
119122 Effect.gen(function* () {
120123 if (input.source.auth.kind !== "oauth2") return "";
121124 const auth = input.source.auth;
@@ -158,7 +161,9 @@ const resolveOAuthAccessToken = (input: { · 36 unmodified lines
158161 });
159162 }),
160163 }).pipe(
161− Effect.mapError((error) => new Error(error.message)),
164+ Effect.mapError(
165+ (error) => new GoogleDiscoveryOAuthError({ message: error.message }),
166+ ),
162167 );
163168 });
164169
@@ -236,12 +241,23 @@ const performRequest = Effect.fn("GoogleDiscovery.invoke")(function* (input: { · 71 unmodified lines
236241 );
237242
238243 const contentType = response.headers["content-type"] ?? null;
244+ const mapBodyError = Effect.mapError(
245+ (err: { readonly message?: string }) =>
246+ new GoogleDiscoveryInvocationError({
247+ message: `Failed to read response body: ${err.message ?? String(err)}`,
248+ statusCode: Option.some(response.status),
249+ cause: err,
250+ }),
251+ );
239252 const body =
240253 response.status === 204
241254 ? null
242255 : isJsonContentType(contentType)
243− ? yield* response.json.pipe(Effect.catchAll(() => response.text))
244− : yield* response.text;
256+ ? yield* response.json.pipe(
257+ Effect.catchAll(() => response.text),
258+ mapBodyError,
259+ )
260+ : yield* response.text.pipe(mapBodyError);
245261
246262 const ok = response.status >= 200 && response.status < 300;
247263 return new GoogleDiscoveryInvocationResult({
@@ -261,18 +277,29 @@ export const invokeGoogleDiscoveryTool = (input: { · 13 unmodified lines
261277 toolId: string;
262278 args: unknown;
263279 httpClientLayer?: Layer.Layer<HttpClient.HttpClient>;
264−}): Effect.Effect<GoogleDiscoveryInvocationResult, Error> =>
280+}): Effect.Effect<
281+ GoogleDiscoveryInvocationResult,
282+ | GoogleDiscoveryInvocationError
283+ | GoogleDiscoveryOAuthError
284+ | StorageFailure
285+> =>
265286 Effect.gen(function* () {
266287 const entry = yield* input.ctx.storage.getBinding(input.toolId);
267288 if (!entry) {
268289 return yield* Effect.fail(
269− new Error(`No Google Discovery operation found for tool "${input.toolId}"`),
290+ new GoogleDiscoveryInvocationError({
291+ message: `No Google Discovery operation found for tool "${input.toolId}"`,
292+ statusCode: Option.none(),
293+ }),
270294 );
271295 }
272296 const source = yield* input.ctx.storage.getSourceConfig(entry.namespace);
273297 if (!source) {
274298 return yield* Effect.fail(
275− new Error(`No Google Discovery source found for "${entry.namespace}"`),
299+ new GoogleDiscoveryInvocationError({
300+ message: `No Google Discovery source found for "${entry.namespace}"`,
301+ statusCode: Option.none(),
302+ }),
276303 );
277304 }
278305
@@ -298,8 +325,5 @@ export const invokeGoogleDiscoveryTool = (input: { · 19 unmodified lines
298325 source,
299326 args: (input.args ?? {}) as Record<string, unknown>,
300327 authorizationHeader: authHeader,
301− }).pipe(
302− Effect.provide(layer),
303− Effect.mapError((err) => (err instanceof Error ? err : new Error(String(err)))),
304− );
328+ }).pipe(Effect.provide(layer));
305329 });
packages/plugins/google-discovery/src/sdk/plugin.ts+34−16
@@ -9,6 +9,7 @@ import { · 8 unmodified lines
99 SetSecretInput,
1010 SourceDetectionResult,
1111 type PluginCtx,
12+ type StorageFailure,
1213 type ToolAnnotations,
1314 } from "@executor/sdk";
1415
@@ -99,6 +100,23 @@ export interface GoogleDiscoveryOAuthAuthResult { · 84 unmodified lines
99100 readonly scopes: readonly string[];
100101 }
101102
103+/**
104+ * Errors any Google Discovery extension method may surface. The first
105+ * three are plugin-domain tagged errors that flow directly to clients
106+ * (4xx, each carrying its own `HttpApiSchema` status once wrapped at the
107+ * API edge). `StorageFailure` covers raw backend failures (`StorageError`)
108+ * plus `UniqueViolationError`; the HTTP edge (`@executor/api`'s
109+ * `withCapture`) translates `StorageError` to the opaque
110+ * `InternalError({ traceId })` at Layer composition. `UniqueViolationError`
111+ * passes through — plugins can `Effect.catchTag` it if they want a
112+ * friendlier user-facing error.
113+ */
114+export type GoogleDiscoveryExtensionFailure =
115+ | GoogleDiscoveryParseError
116+ | GoogleDiscoverySourceError
117+ | GoogleDiscoveryOAuthError
118+ | StorageFailure;
119+
102120 export interface GoogleDiscoveryPluginExtension {
103121 readonly probeDiscovery: (
104122 discoveryUrl: string,
@@ -110,21 +128,29 @@ export interface GoogleDiscoveryPluginExtension { · 5 unmodified lines
110128 input: GoogleDiscoveryAddSourceInput,
111129 ) => Effect.Effect<
112130 { readonly toolCount: number; readonly namespace: string },
113− GoogleDiscoveryParseError | GoogleDiscoverySourceError | Error
131+ GoogleDiscoveryParseError | GoogleDiscoverySourceError | StorageFailure
114132 >;
115− readonly removeSource: (namespace: string) => Effect.Effect<void, Error>;
133+ readonly removeSource: (
134+ namespace: string,
135+ ) => Effect.Effect<void, StorageFailure>;
116136 readonly startOAuth: (
117137 input: GoogleDiscoveryOAuthStartInput,
118138 ) => Effect.Effect<
119139 GoogleDiscoveryOAuthStartResponse,
120− GoogleDiscoveryParseError | GoogleDiscoverySourceError | GoogleDiscoveryOAuthError | Error
140+ | GoogleDiscoveryParseError
141+ | GoogleDiscoverySourceError
142+ | GoogleDiscoveryOAuthError
143+ | StorageFailure
121144 >;
122145 readonly completeOAuth: (
123146 input: GoogleDiscoveryOAuthCompleteInput,
124− ) => Effect.Effect<GoogleDiscoveryOAuthAuthResult, GoogleDiscoveryOAuthError>;
147+ ) => Effect.Effect<
148+ GoogleDiscoveryOAuthAuthResult,
149+ GoogleDiscoveryOAuthError | StorageFailure
150+ >;
125151 readonly getSource: (
126152 namespace: string,
127− ) => Effect.Effect<GoogleDiscoveryStoredSource | null, Error>;
153+ ) => Effect.Effect<GoogleDiscoveryStoredSource | null, StorageFailure>;
128154 }
129155
130156 // ---------------------------------------------------------------------------
@@ -286,7 +312,7 @@ export const googleDiscoveryPlugin = definePlugin(() => ({ · 155 unmodified lines
286312 schema: googleDiscoverySchema,
287313 storage: (deps) => makeGoogleDiscoveryStore(deps),
288314
289− extension: (ctx): GoogleDiscoveryPluginExtension => ({
315+ extension: (ctx) => ({
290316 probeDiscovery: (discoveryUrl) =>
291317 Effect.gen(function* () {
292318 const text = yield* fetchDiscoveryDocument(discoveryUrl);
@@ -465,18 +491,10 @@ export const googleDiscoveryPlugin = definePlugin(() => ({ · 172 unmodified lines
465491 scope: stored.scope,
466492 scopes: [...session.scopes],
467493 };
468− }).pipe(
469− Effect.mapError((err) =>
470− err instanceof GoogleDiscoveryOAuthError
471− ? err
472− : new GoogleDiscoveryOAuthError({
473− message: err instanceof Error ? err.message : String(err),
474− }),
475− ),
476− ),
494+ }),
477495
478496 getSource: (namespace) => ctx.storage.getSource(namespace),
479− }),
497+ } satisfies GoogleDiscoveryPluginExtension),
480498
481499 invokeTool: ({ ctx, toolRow, args }) =>
482500 invokeGoogleDiscoveryTool({
packages/plugins/graphql/src/api/group.ts+23−17
@@ -1,6 +1,7 @@
11 import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "@effect/platform";
22 import { Schema } from "effect";
33 import { ScopeId } from "@executor/sdk";
4+import { InternalError } from "@executor/api";
45
56 import { GraphqlIntrospectionError, GraphqlExtractionError } from "../sdk/errors";
67 import { HeaderValue } from "../sdk/types";
@@ -63,35 +64,40 @@ const ExtractionError = GraphqlExtractionError.annotations( · 56 unmodified lines
6364 HttpApiSchema.annotations({ status: 400 }),
6465 );
6566
66−export class GraphqlInternalError extends Schema.TaggedError<GraphqlInternalError>()(
67− "GraphqlInternalError",
68− {
69− message: Schema.String,
70− },
71− HttpApiSchema.annotations({ status: 500 }),
72−) {}
73−
7467 // ---------------------------------------------------------------------------
7568 // Group
69+//
70+// Plugin SDK errors (GraphqlIntrospectionError etc.) are declared once at
71+// the group level via `.addError(...)` — every endpoint inherits them. The
72+// errors themselves carry their HTTP status via `HttpApiSchema.annotations`
73+// above, so handlers just `return yield* ext.foo(...)` and the schema
74+// encodes whatever it gets.
75+//
76+// 5xx is handled at the API level: `.addError(InternalError)` adds a
77+// single shared opaque-by-schema 500 surface translated from `StorageError`
78+// by `withCapture` at the HTTP edge. No per-handler wrapping, no
79+// per-plugin InternalError.
7680 // ---------------------------------------------------------------------------
7781
7882 export class GraphqlGroup extends HttpApiGroup.make("graphql")
7983 .add(
8084 HttpApiEndpoint.post("addSource")`/scopes/${scopeIdParam}/graphql/sources`
8185 .setPayload(AddSourcePayload)
82− .addSuccess(AddSourceResponse)
83− .addError(IntrospectionError)
84− .addError(ExtractionError)
85− .addError(GraphqlInternalError),
86+ .addSuccess(AddSourceResponse),
8687 )
8788 .add(
8889 HttpApiEndpoint.get("getSource")`/scopes/${scopeIdParam}/graphql/sources/${namespaceParam}`
89− .addSuccess(Schema.NullOr(StoredSourceSchema))
90− .addError(GraphqlInternalError),
90+ .addSuccess(Schema.NullOr(StoredSourceSchema)),
9191 )
9292 .add(
9393 HttpApiEndpoint.patch("updateSource")`/scopes/${scopeIdParam}/graphql/sources/${namespaceParam}`
9494 .setPayload(UpdateSourcePayload)
95− .addSuccess(UpdateSourceResponse)
96− .addError(GraphqlInternalError),
97− ) {}
95+ .addSuccess(UpdateSourceResponse),
96+ )
97+ // Errors declared once at the group level — every endpoint inherits.
98+ // Plugin domain errors carry their own HttpApiSchema status (4xx);
99+ // `InternalError` is the shared opaque 500 translated at the HTTP
100+ // edge by `withCapture`.
101+ .addError(InternalError)
102+ .addError(IntrospectionError)
103+ .addError(ExtractionError) {}
packages/plugins/graphql/src/api/handlers.ts+28−53
@@ -1,13 +1,23 @@
11 import { HttpApiBuilder } from "@effect/platform";
2−import { Cause, Context, Effect } from "effect";
2+import { Context, Effect } from "effect";
33
4−import { addGroup } from "@executor/api";
5−import { GraphqlExtractionError, GraphqlIntrospectionError } from "../sdk/errors";
6−import type { GraphqlPluginExtension, HeaderValue, GraphqlUpdateSourceInput } from "../sdk/plugin";
7−import { GraphqlGroup, GraphqlInternalError } from "./group";
4+import { addGroup, capture } from "@executor/api";
5+import type {
6+ GraphqlPluginExtension,
7+ HeaderValue,
8+ GraphqlUpdateSourceInput,
9+} from "../sdk/plugin";
10+import { GraphqlGroup } from "./group";
811
912 // ---------------------------------------------------------------------------
10−// Service tag — the server provides the GraphQL extension
13+// Service tag
14+//
15+// Holds the `Captured` shape — every method's `StorageError` channel has
16+// been swapped for `InternalError({ traceId })`. The host app provides an
17+// already-wrapped extension via
18+// `Layer.succeed(GraphqlExtensionService, withCapture(executor.graphql))`.
19+// Handlers see `InternalError` in the error union, which matches
20+// `.addError(InternalError)` on the group — no per-handler translation.
1121 // ---------------------------------------------------------------------------
1222
1323 export class GraphqlExtensionService extends Context.Tag("GraphqlExtensionService")<
@@ -15,47 +25,6 @@ export class GraphqlExtensionService extends Context.Tag("GraphqlExtensionServic · 1 unmodified line
1525 GraphqlPluginExtension
1626 >() {}
1727
18−// ---------------------------------------------------------------------------
19−// Failure mapping
20−// ---------------------------------------------------------------------------
21−
22−type GraphqlAddSourceFailure =
23− | GraphqlIntrospectionError
24− | GraphqlExtractionError
25− | GraphqlInternalError;
26−
27−const toGraphqlAddSourceFailure = (error: unknown): GraphqlAddSourceFailure => {
28− if (
29− error instanceof GraphqlIntrospectionError ||
30− error instanceof GraphqlExtractionError ||
31− error instanceof GraphqlInternalError
32− ) {
33− return error;
34− }
35− const message = error instanceof Error ? error.message : String(error);
36− return new GraphqlInternalError({ message });
37−};
38−
39−const sanitizeAddSourceFailure = <A, R>(
40− effect: Effect.Effect<A, unknown, R>,
41−): Effect.Effect<A, GraphqlAddSourceFailure, R> =>
42− Effect.catchAllCause(effect, (cause) =>
43− Effect.fail(toGraphqlAddSourceFailure(Cause.squash(cause))),
44− );
45−
46−const toGraphqlInternalError = (error: unknown): GraphqlInternalError => {
47− if (error instanceof GraphqlInternalError) return error;
48− const message = error instanceof Error ? error.message : String(error);
49− return new GraphqlInternalError({ message });
50−};
51−
52−const sanitizeInternalFailure = <A, R>(
53− effect: Effect.Effect<A, unknown, R>,
54−): Effect.Effect<A, GraphqlInternalError, R> =>
55− Effect.catchAllCause(effect, (cause) =>
56− Effect.fail(toGraphqlInternalError(Cause.squash(cause))),
57− );
58−
5928 // ---------------------------------------------------------------------------
6029 // Composed API — core + graphql group
6130 // ---------------------------------------------------------------------------
@@ -64,12 +33,18 @@ const ExecutorApiWithGraphql = addGroup(GraphqlGroup); · 2 unmodified lines
6433
6534 // ---------------------------------------------------------------------------
6635 // Handlers
36+//
37+// Each handler is exactly: yield the extension service, call the method,
38+// return. Plugin SDK errors flow through the typed channel and are
39+// schema-encoded to 4xx by HttpApi (see group.ts `.addError(...)` calls).
40+// Defects bubble up and are captured + downgraded to `InternalError(traceId)`
41+// by the API-level observability middleware.
6742 // ---------------------------------------------------------------------------
6843
6944 export const GraphqlHandlers = HttpApiBuilder.group(ExecutorApiWithGraphql, "graphql", (handlers) =>
7045 handlers
7146 .handle("addSource", ({ payload }) =>
72− Effect.gen(function* () {
47+ capture(Effect.gen(function* () {
7348 const ext = yield* GraphqlExtensionService;
7449 const result = yield* ext.addSource({
7550 endpoint: payload.endpoint,
@@ -82,16 +57,16 @@ export const GraphqlHandlers = HttpApiBuilder.group(ExecutorApiWithGraphql, "gra · 6 unmodified lines
8257 toolCount: result.toolCount,
8358 namespace: payload.namespace ?? "graphql",
8459 };
85− }).pipe(sanitizeAddSourceFailure),
60+ })),
8661 )
8762 .handle("getSource", ({ path }) =>
88− Effect.gen(function* () {
63+ capture(Effect.gen(function* () {
8964 const ext = yield* GraphqlExtensionService;
9065 return yield* ext.getSource(path.namespace);
91− }).pipe(sanitizeInternalFailure),
66+ })),
9267 )
9368 .handle("updateSource", ({ path, payload }) =>
94− Effect.gen(function* () {
69+ capture(Effect.gen(function* () {
9570 const ext = yield* GraphqlExtensionService;
9671 yield* ext.updateSource(path.namespace, {
9772 name: payload.name,
@@ -99,6 +74,6 @@ export const GraphqlHandlers = HttpApiBuilder.group(ExecutorApiWithGraphql, "gra · 1 unmodified line
9974 headers: payload.headers as Record<string, HeaderValue> | undefined,
10075 } as GraphqlUpdateSourceInput);
10176 return { updated: true };
102− }).pipe(sanitizeInternalFailure),
77+ })),
10378 ),
10479 );
packages/plugins/graphql/src/sdk/invoke.ts+5−5
@@ -14,8 +14,8 @@ import { · 13 unmodified lines
1414
1515 export const resolveHeaders = (
1616 headers: Record<string, HeaderValue>,
17− secrets: { readonly get: (id: string) => Effect.Effect<string | null, Error> },
18−): Effect.Effect<Record<string, string>, Error> =>
17+ secrets: { readonly get: (id: string) => Effect.Effect<string | null, unknown> },
18+): Effect.Effect<Record<string, string>> =>
1919 Effect.gen(function* () {
2020 const entries = Object.entries(headers);
2121 // Resolve secret-backed headers in parallel. Missing / failing
@@ -138,14 +138,14 @@ export const invokeWithLayer = ( · 116 unmodified lines
138138 endpoint: string,
139139 resolvedHeaders: Record<string, string>,
140140 httpClientLayer: Layer.Layer<HttpClient.HttpClient>,
141−): Effect.Effect<InvocationResult, Error> =>
141+) =>
142142 invoke(operation, args, endpoint, resolvedHeaders).pipe(
143143 Effect.provide(httpClientLayer),
144144 Effect.mapError((err) =>
145− err instanceof Error
145+ err instanceof GraphqlInvocationError
146146 ? err
147147 : new GraphqlInvocationError({
148− message: String(err),
148+ message: err instanceof Error ? err.message : String(err),
149149 statusCode: Option.none(),
150150 cause: err,
151151 }),
packages/plugins/graphql/src/sdk/plugin.ts+24−14
@@ -5,6 +5,7 @@ import type { Layer } from "effect"; · 4 unmodified lines
55 import {
66 definePlugin,
77 SourceDetectionResult,
8+ type StorageFailure,
89 type ToolAnnotations,
910 type ToolRow,
1011 } from "@executor/sdk";
@@ -24,7 +25,7 @@ import { · 13 unmodified lines
2425 type IntrospectionTypeRef,
2526 } from "./introspect";
2627 import { extract } from "./extract";
27−import { GraphqlExtractionError } from "./errors";
28+import { GraphqlExtractionError, GraphqlIntrospectionError } from "./errors";
2829 import { invokeWithLayer, resolveHeaders } from "./invoke";
2930 import {
3031 graphqlSchema,
@@ -69,19 +70,35 @@ export interface GraphqlUpdateSourceInput { · 38 unmodified lines
6970 readonly headers?: Record<string, HeaderValue>;
7071 }
7172
73+/**
74+ * Errors any GraphQL extension method may surface. `GraphqlIntrospectionError`
75+ * and `GraphqlExtractionError` are plugin-domain tagged errors that flow
76+ * directly to clients (4xx, each carrying its own `HttpApiSchema` status).
77+ * `StorageFailure` covers raw backend failures (`StorageError` plus
78+ * `UniqueViolationError`); the HTTP edge (`@executor/api`'s `withCapture`)
79+ * translates `StorageError` to the opaque `InternalError({ traceId })` at
80+ * Layer composition.
81+ */
82+export type GraphqlExtensionFailure =
83+ | GraphqlIntrospectionError
84+ | GraphqlExtractionError
85+ | StorageFailure;
86+
7287 export interface GraphqlPluginExtension {
7388 /** Add a GraphQL endpoint and register its operations as tools */
7489 readonly addSource: (
7590 config: GraphqlSourceConfig,
76− ) => Effect.Effect<{ readonly toolCount: number }, Error>;
91+ ) => Effect.Effect<{ readonly toolCount: number }, GraphqlExtensionFailure>;
7792
7893 /** Remove all tools from a previously added GraphQL source by namespace */
79− readonly removeSource: (namespace: string) => Effect.Effect<void, Error>;
94+ readonly removeSource: (
95+ namespace: string,
96+ ) => Effect.Effect<void, StorageFailure>;
8097
8198 /** Fetch the full stored source by namespace (or null if missing) */
8299 readonly getSource: (
83100 namespace: string,
84− ) => Effect.Effect<StoredGraphqlSource | null, Error>;
101+ ) => Effect.Effect<StoredGraphqlSource | null, StorageFailure>;
85102
86103 /** Update config (endpoint, headers) for an existing GraphQL source.
87104 * Does NOT re-introspect or re-register tools — just patches the
@@ -89,7 +106,7 @@ export interface GraphqlPluginExtension { · 1 unmodified line
89106 readonly updateSource: (
90107 namespace: string,
91108 input: GraphqlUpdateSourceInput,
92− ) => Effect.Effect<void, Error>;
109+ ) => Effect.Effect<void, StorageFailure>;
93110 }
94111
95112 // ---------------------------------------------------------------------------
@@ -282,7 +299,7 @@ export const graphqlPlugin = definePlugin( · 186 unmodified lines
282299 schema: graphqlSchema,
283300 storage: (deps): GraphqlStore => makeDefaultGraphqlStore(deps),
284301
285− extension: (ctx): GraphqlPluginExtension => {
302+ extension: (ctx) => {
286303 const resolveConfigHeaders = (
287304 headers: Record<string, HeaderValue> | undefined,
288305 ) =>
@@ -368,13 +385,6 @@ export const graphqlPlugin = definePlugin( · 79 unmodified lines
368385 return {
369386 addSource: (config) =>
370387 addSourceInternal(config).pipe(
371− Effect.mapError((err) =>
372− err instanceof Error
373− ? err
374− : new GraphqlExtractionError({
375− message: String(err),
376− }),
377− ),
378388 Effect.tap((result) =>
379389 configFile
380390 ? configFile.upsertSource(
@@ -406,7 +416,7 @@ export const graphqlPlugin = definePlugin( · 25 unmodified lines
406416 endpoint: input.endpoint,
407417 headers: input.headers,
408418 }),
409− };
419+ } satisfies GraphqlPluginExtension;
410420 },
411421
412422 staticSources: (self) => [
packages/plugins/graphql/src/sdk/store.ts+8−8
@@ -1,6 +1,6 @@
11 import { Effect } from "effect";
22
3−import { defineSchema, type StorageDeps } from "@executor/sdk";
3+import { defineSchema, type StorageDeps, type StorageFailure } from "@executor/sdk";
44
55 import { OperationBinding, type HeaderValue } from "./types";
66
@@ -89,28 +89,28 @@ export interface GraphqlStore { · 82 unmodified lines
8989 readonly upsertSource: (
9090 input: StoredGraphqlSource,
9191 operations: readonly StoredOperation[],
92− ) => Effect.Effect<void, Error>;
92+ ) => Effect.Effect<void, StorageFailure>;
9393
9494 readonly updateSourceMeta: (
9595 namespace: string,
9696 patch: { readonly name?: string; readonly endpoint?: string; readonly headers?: Record<string, HeaderValue> },
97− ) => Effect.Effect<void, Error>;
97+ ) => Effect.Effect<void, StorageFailure>;
9898
9999 readonly getSource: (
100100 namespace: string,
101− ) => Effect.Effect<StoredGraphqlSource | null, Error>;
101+ ) => Effect.Effect<StoredGraphqlSource | null, StorageFailure>;
102102
103− readonly listSources: () => Effect.Effect<readonly StoredGraphqlSource[], Error>;
103+ readonly listSources: () => Effect.Effect<readonly StoredGraphqlSource[], StorageFailure>;
104104
105105 readonly getOperationByToolId: (
106106 toolId: string,
107− ) => Effect.Effect<StoredOperation | null, Error>;
107+ ) => Effect.Effect<StoredOperation | null, StorageFailure>;
108108
109109 readonly listOperationsBySource: (
110110 sourceId: string,
111− ) => Effect.Effect<readonly StoredOperation[], Error>;
111+ ) => Effect.Effect<readonly StoredOperation[], StorageFailure>;
112112
113− readonly removeSource: (namespace: string) => Effect.Effect<void, Error>;
113+ readonly removeSource: (namespace: string) => Effect.Effect<void, StorageFailure>;
114114 }
115115
116116 // ---------------------------------------------------------------------------
packages/plugins/keychain/src/provider.ts+18−4
@@ -1,21 +1,35 @@
11 import { Effect } from "effect";
22
3−import type { SecretProvider } from "@executor/sdk";
3+import { StorageError, type SecretProvider } from "@executor/sdk";
44
55 import { getPassword, setPassword, deletePassword } from "./keyring";
66
77 // ---------------------------------------------------------------------------
88 // SecretProvider adapter — bridges keyring into SDK resolution chain
9+//
10+// The underlying `@napi-rs/keyring` sync API encodes "no entry" as an
11+// ordinary return value (`getPassword()` → `null`, `deletePassword()` →
12+// `false`), and only throws on real failures (keychain locked, permission
13+// denied, platform init failure, etc.). `keyring.ts` wraps those thrown
14+// failures as `KeychainError`. We translate `KeychainError` →
15+// `StorageError` so the HTTP edge can capture it to telemetry and surface
16+// an opaque `InternalError({ traceId })` — previously `orElseSucceed`
17+// silently converted every failure into "nothing found", which made it
18+// impossible to debug why secrets weren't resolving.
919 // ---------------------------------------------------------------------------
1020
21+const toStorageError = (cause: { readonly message: string; readonly cause?: unknown }) =>
22+ new StorageError({ message: cause.message, cause: cause.cause ?? cause });
23+
1124 export const makeKeychainProvider = (serviceName: string): SecretProvider => ({
1225 key: "keychain",
1326 writable: true,
14− get: (secretId) => getPassword(serviceName, secretId).pipe(Effect.orElseSucceed(() => null)),
27+ get: (secretId) =>
28+ getPassword(serviceName, secretId).pipe(Effect.mapError(toStorageError)),
1529 set: (secretId, value) =>
16− setPassword(serviceName, secretId, value).pipe(Effect.orElseSucceed(() => undefined)),
30+ setPassword(serviceName, secretId, value).pipe(Effect.mapError(toStorageError)),
1731 delete: (secretId) =>
18− deletePassword(serviceName, secretId).pipe(Effect.orElseSucceed(() => false)),
32+ deletePassword(serviceName, secretId).pipe(Effect.mapError(toStorageError)),
1933 // Keychain doesn't support enumerating — you need to know the account name
2034 list: undefined,
2135 });
packages/plugins/mcp/src/api/group.ts+39−48
@@ -1,7 +1,13 @@
11 import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "@effect/platform";
22 import { Schema } from "effect";
33 import { ScopeId } from "@executor/sdk";
4+import { InternalError } from "@executor/api";
45
6+import {
7+ McpConnectionError,
8+ McpOAuthError,
9+ McpToolDiscoveryError,
10+} from "../sdk/errors";
511 import { McpStoredSourceSchema } from "../sdk/stored-source";
612
713 // Re-export for handler use
@@ -149,90 +155,75 @@ const CompleteOAuthResponse = Schema.Struct({ · 141 unmodified lines
149155 scope: Schema.NullOr(Schema.String),
150156 });
151157
152−// ---------------------------------------------------------------------------
153−// Error
154−// ---------------------------------------------------------------------------
155−
156−export class McpApiError extends Schema.TaggedError<McpApiError>()(
157− "McpApiError",
158− {
159− message: Schema.String,
160− },
161− HttpApiSchema.annotations({ status: 400 }),
162−) {}
163−
164−export class McpInternalError extends Schema.TaggedError<McpInternalError>()(
165− "McpInternalError",
166− {
167− message: Schema.String,
168− },
169− HttpApiSchema.annotations({ status: 500 }),
170−) {}
171−
172158 // ---------------------------------------------------------------------------
173159 // Group
160+//
161+// Plugin SDK errors (McpOAuthError etc.) are declared once at the group
162+// level via `.addError(...)` — every endpoint inherits them. The errors
163+// themselves carry their HTTP status via `HttpApiSchema.annotations`
164+// in errors.ts, so handlers just `return yield* ext.foo(...)` and the
165+// schema encodes whatever it gets.
166+//
167+// 5xx is handled at the API level: `CoreExecutorApi.addError(InternalError)`
168+// adds a single shared opaque-by-schema 500 surface to every endpoint in
169+// the entire API. Defects are captured + downgraded to it by an
170+// HttpApiBuilder middleware (see apps/cloud/src/observability.ts).
171+// No per-handler wrapping, no per-plugin InternalError.
174172 // ---------------------------------------------------------------------------
175173
176174 export class McpGroup extends HttpApiGroup.make("mcp")
177175 .add(
178176 HttpApiEndpoint.post("probeEndpoint")`/scopes/${scopeIdParam}/mcp/probe`
179177 .setPayload(ProbeEndpointPayload)
180− .addSuccess(ProbeEndpointResponse)
181− .addError(McpApiError)
182− .addError(McpInternalError),
178+ .addSuccess(ProbeEndpointResponse),
183179 )
184180 .add(
185181 HttpApiEndpoint.post("addSource")`/scopes/${scopeIdParam}/mcp/sources`
186182 .setPayload(AddSourcePayload)
187− .addSuccess(AddSourceResponse)
188− .addError(McpApiError)
189− .addError(McpInternalError),
183+ .addSuccess(AddSourceResponse),
190184 )
191185 .add(
192186 HttpApiEndpoint.post("removeSource")`/scopes/${scopeIdParam}/mcp/sources/remove`
193187 .setPayload(NamespacePayload)
194− .addSuccess(RemoveSourceResponse)
195− .addError(McpApiError)
196− .addError(McpInternalError),
188+ .addSuccess(RemoveSourceResponse),
197189 )
198190 .add(
199191 HttpApiEndpoint.post("refreshSource")`/scopes/${scopeIdParam}/mcp/sources/refresh`
200192 .setPayload(NamespacePayload)
201− .addSuccess(RefreshSourceResponse)
202− .addError(McpApiError)
203− .addError(McpInternalError),
193+ .addSuccess(RefreshSourceResponse),
204194 )
205195 .add(
206196 HttpApiEndpoint.post("startOAuth")`/scopes/${scopeIdParam}/mcp/oauth/start`
207197 .setPayload(StartOAuthPayload)
208− .addSuccess(StartOAuthResponse)
209− .addError(McpApiError)
210− .addError(McpInternalError),
198+ .addSuccess(StartOAuthResponse),
211199 )
212200 .add(
213201 HttpApiEndpoint.post("completeOAuth")`/scopes/${scopeIdParam}/mcp/oauth/complete`
214202 .setPayload(CompleteOAuthPayload)
215− .addSuccess(CompleteOAuthResponse)
216− .addError(McpApiError)
217− .addError(McpInternalError),
203+ .addSuccess(CompleteOAuthResponse),
218204 )
219205 .add(
220206 HttpApiEndpoint.get("oauthCallback")`/mcp/oauth/callback`
221207 .setUrlParams(OAuthCallbackParams)
222− .addSuccess(HtmlResponse)
223− .addError(McpApiError)
224− .addError(McpInternalError),
208+ .addSuccess(HtmlResponse),
225209 )
226210 .add(
227211 HttpApiEndpoint.get("getSource")`/scopes/${scopeIdParam}/mcp/sources/${namespaceParam}`
228− .addSuccess(Schema.NullOr(McpStoredSourceSchema))
229− .addError(McpApiError)
230− .addError(McpInternalError),
212+ .addSuccess(Schema.NullOr(McpStoredSourceSchema)),
231213 )
232214 .add(
233215 HttpApiEndpoint.patch("updateSource")`/scopes/${scopeIdParam}/mcp/sources/${namespaceParam}`
234216 .setPayload(UpdateSourcePayload)
235− .addSuccess(UpdateSourceResponse)
236− .addError(McpApiError)
237− .addError(McpInternalError),
238− ) {}
217+ .addSuccess(UpdateSourceResponse),
218+ )
219+ // Errors declared once at the group level — every endpoint inherits.
220+ // Plugin domain errors carry their own HttpApiSchema status (4xx);
221+ // `InternalError` is the shared opaque 500 translated at the HTTP
222+ // edge by `withCapture`. We only list errors an MCP *group*
223+ // endpoint can surface: `McpInvocationError` is thrown inside
224+ // `invokeTool` which is reached via the core `tools.invoke`
225+ // endpoint, not any MCP-group endpoint, so it doesn't belong here.
226+ .addError(InternalError)
227+ .addError(McpOAuthError)
228+ .addError(McpConnectionError)
229+ .addError(McpToolDiscoveryError) {}
packages/plugins/mcp/src/api/handlers.test.ts+59−40
@@ -1,8 +1,17 @@
1+// ---------------------------------------------------------------------------
2+// Handler-level integration test for the MCP group.
3+//
4+// Verifies the layer wiring stays coherent end-to-end: the handlers
5+// pull the wrapped extension from the service, and any un-caught cause
6+// lands in the observability middleware — producing a 500 whose body is
7+// the opaque `InternalError` schema (no internal leakage).
8+// ---------------------------------------------------------------------------
9+
110 import { HttpApiBuilder, HttpServer } from "@effect/platform";
2−import { describe, expect, it } from "vitest";
11+import { describe, expect, it } from "@effect/vitest";
312 import { Effect, Layer } from "effect";
413
5−import { addGroup } from "@executor/api";
14+import { addGroup, observabilityMiddleware } from "@executor/api";
615 import { CoreHandlers, ExecutionEngineService, ExecutorService } from "@executor/api/server";
716 import type { McpPluginExtension } from "../sdk/plugin";
817 import { McpExtensionService, McpHandlers } from "./handlers";
@@ -10,7 +19,7 @@ import { McpGroup } from "./group"; · 1 unmodified line
1019
1120 const unused = Effect.dieMessage("unused");
1221
13−const createFailingExtension = (): McpPluginExtension => ({
22+const failingExtension: McpPluginExtension = {
1423 probeEndpoint: () => Effect.die(new Error("Not implemented")),
1524 addSource: () => unused,
1625 removeSource: () => unused,
@@ -19,48 +28,58 @@ const createFailingExtension = (): McpPluginExtension => ({ · 2 unmodified lines
1928 completeOAuth: () => Effect.die(new Error("Not implemented")),
2029 getSource: () => Effect.succeed(null),
2130 updateSource: () => unused,
22−});
31+};
2332
2433 const Api = addGroup(McpGroup);
2534
26−const fakeExecutor = {} as any;
27−const fakeExecutionEngine = {} as any;
28−
29−const createHandler = () =>
30− HttpApiBuilder.toWebHandler(
31− HttpApiBuilder.api(Api).pipe(
32− Layer.provide(CoreHandlers),
33− Layer.provide(McpHandlers),
34− Layer.provide(Layer.succeed(ExecutorService, fakeExecutor)),
35− Layer.provide(Layer.succeed(ExecutionEngineService, fakeExecutionEngine)),
36− Layer.provide(Layer.succeed(McpExtensionService, createFailingExtension())),
37− Layer.provideMerge(HttpServer.layerContext),
38− Layer.provideMerge(HttpApiBuilder.Router.Live),
39− Layer.provideMerge(HttpApiBuilder.Middleware.layer),
35+// `acquireRelease` keeps disposal inside the Effect scope — no
36+// try/finally, no per-test cleanup plumbing. `it.scoped` closes the
37+// scope for us. Each `Layer.provide` satisfies a piece of the api
38+// builder's dependency graph; `provideMerge` at the bottom keeps
39+// framework services available to the router itself.
40+const WebHandler = Effect.acquireRelease(
41+ Effect.sync(() =>
42+ HttpApiBuilder.toWebHandler(
43+ HttpApiBuilder.api(Api).pipe(
44+ Layer.provide(CoreHandlers),
45+ Layer.provide(McpHandlers),
46+ Layer.provide(observabilityMiddleware(Api)),
47+ Layer.provide(Layer.succeed(ExecutorService, {} as never)),
48+ Layer.provide(Layer.succeed(ExecutionEngineService, {} as never)),
49+ Layer.provide(Layer.succeed(McpExtensionService, failingExtension)),
50+ Layer.provideMerge(HttpServer.layerContext),
51+ Layer.provideMerge(HttpApiBuilder.Router.Live),
52+ Layer.provideMerge(HttpApiBuilder.Middleware.layer),
53+ ),
4054 ),
41− );
55+ ),
56+ (web) => Effect.promise(() => web.dispose()),
57+);
4258
4359 describe("McpHandlers", () => {
44− it("sanitizes unknown endpoint failures", async () => {
45− const web = createHandler();
46− try {
47− const response = await web.handler(
48− new Request("http://localhost/scopes/scope_1/mcp/probe", {
49− method: "POST",
50− headers: { "content-type": "application/json" },
51− body: JSON.stringify({ endpoint: "https://example.com/mcp" }),
52− }),
53− );
60+ it.scoped(
61+ "defect-returning methods produce an opaque InternalError, no leakage",
62+ () =>
63+ Effect.gen(function* () {
64+ const web = yield* WebHandler;
65+ const response = yield* Effect.promise(() =>
66+ web.handler(
67+ new Request("http://localhost/scopes/scope_1/mcp/probe", {
68+ method: "POST",
69+ headers: { "content-type": "application/json" },
70+ body: JSON.stringify({ endpoint: "https://example.com/mcp" }),
71+ }),
72+ ),
73+ );
5474
55− expect(response.status).toBe(500);
56− const body = await response.json();
57− expect(body).toEqual({
58− _tag: "McpInternalError",
59− message: "Internal server error",
60− });
61− expect(JSON.stringify(body)).not.toContain("Not implemented");
62− } finally {
63− await web.dispose();
64− }
65− });
75+ expect(response.status).toBe(500);
76+ const body = (yield* Effect.promise(() => response.json())) as {
77+ _tag?: string;
78+ traceId?: string;
79+ };
80+ expect(body._tag).toBe("InternalError");
81+ expect(typeof body.traceId).toBe("string");
82+ expect(JSON.stringify(body)).not.toContain("Not implemented");
83+ }),
84+ );
6685 });
packages/plugins/mcp/src/api/handlers.ts+51−64
@@ -1,18 +1,17 @@
11 import { HttpApiBuilder, HttpServerResponse } from "@effect/platform";
22 import { Cause, Context, Effect } from "effect";
33
4−import { addGroup } from "@executor/api";
4+import { addGroup, capture } from "@executor/api";
55 import type { McpPluginExtension, McpSourceConfig, McpUpdateSourceInput } from "../sdk/plugin";
6−import {
7− McpConnectionError,
8− McpInvocationError,
9− McpOAuthError,
10− McpToolDiscoveryError,
11−} from "../sdk/errors";
12−import { McpApiError, McpGroup, McpInternalError } from "./group";
6+import { McpOAuthError } from "../sdk/errors";
7+import { McpGroup } from "./group";
138
149 // ---------------------------------------------------------------------------
15−// Service tag
10+// Service tag — holds the raw extension shape the executor produces.
11+// Handlers wrap their generator bodies with `capture(...)` from
12+// `@executor/api`, which translates `StorageError` to `InternalError`
13+// at the edge; that's why the tag type matches the SDK shape directly
14+// (no `Captured<>` inversion).
1615 // ---------------------------------------------------------------------------
1716
1817 export class McpExtensionService extends Context.Tag("McpExtensionService")<
@@ -85,33 +84,11 @@ const popupDocument = (payload: OAuthPopupResult): string => { · 66 unmodified lines
8584 </body></html>`;
8685 };
8786
88−const toPopupErrorMessage = (error: unknown): string => {
89− if (error instanceof McpOAuthError) {
90− return error.message;
91− }
92− return "Authentication failed";
93−};
94−
95−const toMcpApiError = (error: unknown): McpApiError | McpInternalError => {
96− if (error instanceof McpApiError || error instanceof McpInternalError) {
97− return error;
98− }
99− if (
100− error instanceof McpConnectionError ||
101− error instanceof McpToolDiscoveryError ||
102− error instanceof McpInvocationError ||
103− error instanceof McpOAuthError
104− ) {
105− return new McpApiError({ message: error.message });
106− }
107− return new McpInternalError({ message: "Internal server error" });
87+const toPopupErrorMessage = (cause: Cause.Cause<unknown>): string => {
88+ const err = Cause.squash(cause);
89+ return err instanceof McpOAuthError ? err.message : "Authentication failed";
10890 };
10991
110−const sanitizeMcpFailure = <A, R>(
111− effect: Effect.Effect<A, unknown, R>,
112−): Effect.Effect<A, McpApiError | McpInternalError, R> =>
113− Effect.catchAllCause(effect, (cause) => Effect.fail(toMcpApiError(Cause.squash(cause))));
114−
11592 // ---------------------------------------------------------------------------
11693 // Convert API payload → McpSourceConfig
11794 // ---------------------------------------------------------------------------
@@ -151,7 +128,6 @@ const toSourceConfig = ( · 33 unmodified lines
151128 auth?: { kind: string } & Record<string, unknown>;
152129 };
153130
154− // Normalize oauth2 tokenType default
155131 const auth = p.auth
156132 ? p.auth.kind === "oauth2"
157133 ? {
@@ -175,65 +151,74 @@ const toSourceConfig = ( · 17 unmodified lines
175151
176152 // ---------------------------------------------------------------------------
177153 // Handlers
154+//
155+// Each handler is exactly: yield the extension service, call the method,
156+// return. Plugin SDK errors flow through the typed channel and are
157+// schema-encoded to 4xx by HttpApi (see group.ts `.addError(...)` calls).
158+// Defects bubble up and are captured + downgraded to `InternalError(traceId)`
159+// by the API-level observability middleware (see apps/cloud/src/observability.ts).
160+//
161+// No `sanitize*`, no `liftDomainErrors`, no `withObservability` per handler.
162+// If you find yourself adding error-handling here you're in the wrong layer.
178163 // ---------------------------------------------------------------------------
179164
180165 export const McpHandlers = HttpApiBuilder.group(ExecutorApiWithMcp, "mcp", (handlers) =>
181166 handlers
182167 .handle("probeEndpoint", ({ payload }) =>
183− Effect.gen(function* () {
168+ capture(Effect.gen(function* () {
184169 const ext = yield* McpExtensionService;
185170 return yield* ext.probeEndpoint(payload.endpoint);
186− }).pipe(sanitizeMcpFailure),
171+ })),
187172 )
188173 .handle("addSource", ({ payload }) =>
189− Effect.gen(function* () {
174+ capture(Effect.gen(function* () {
190175 const ext = yield* McpExtensionService;
191176 return yield* ext.addSource(
192177 toSourceConfig(payload as Parameters<typeof toSourceConfig>[0]),
193178 );
194− }).pipe(sanitizeMcpFailure),
179+ })),
195180 )
196181 .handle("removeSource", ({ payload }) =>
197− Effect.gen(function* () {
182+ capture(Effect.gen(function* () {
198183 const ext = yield* McpExtensionService;
199184 yield* ext.removeSource(payload.namespace);
200185 return { removed: true };
201− }).pipe(sanitizeMcpFailure),
186+ })),
202187 )
203188 .handle("refreshSource", ({ payload }) =>
204− Effect.gen(function* () {
189+ capture(Effect.gen(function* () {
205190 const ext = yield* McpExtensionService;
206191 return yield* ext.refreshSource(payload.namespace);
207− }).pipe(sanitizeMcpFailure),
192+ })),
208193 )
209194 .handle("startOAuth", ({ payload }) =>
210− Effect.gen(function* () {
195+ capture(Effect.gen(function* () {
211196 const ext = yield* McpExtensionService;
212197 return yield* ext.startOAuth({
213198 endpoint: payload.endpoint,
214199 redirectUrl: payload.redirectUrl,
215200 queryParams: payload.queryParams,
216201 });
217− }).pipe(sanitizeMcpFailure),
202+ })),
218203 )
219204 .handle("completeOAuth", ({ payload }) =>
220− Effect.gen(function* () {
205+ capture(Effect.gen(function* () {
221206 const ext = yield* McpExtensionService;
222207 return yield* ext.completeOAuth({
223208 state: payload.state,
224209 code: payload.code,
225210 error: payload.error,
226211 });
227− }).pipe(sanitizeMcpFailure),
212+ })),
228213 )
229214 .handle("getSource", ({ path }) =>
230− Effect.gen(function* () {
215+ capture(Effect.gen(function* () {
231216 const ext = yield* McpExtensionService;
232217 return yield* ext.getSource(path.namespace);
233− }).pipe(sanitizeMcpFailure),
218+ })),
234219 )
235220 .handle("updateSource", ({ path, payload }) =>
236− Effect.gen(function* () {
221+ capture(Effect.gen(function* () {
237222 const ext = yield* McpExtensionService;
238223 yield* ext.updateSource(path.namespace, {
239224 name: payload.name,
@@ -243,20 +228,23 @@ export const McpHandlers = HttpApiBuilder.group(ExecutorApiWithMcp, "mcp", (hand · 3 unmodified lines
243228 auth: payload.auth as McpUpdateSourceInput["auth"],
244229 });
245230 return { updated: true };
246− }).pipe(sanitizeMcpFailure),
231+ })),
247232 )
248233 .handle("oauthCallback", ({ urlParams }) =>
249− Effect.gen(function* () {
234+ // OAuth popup is special: it always returns 200 HTML and renders the
235+ // failure into the popup body so the parent window's listener gets a
236+ // structured result. Catching here is intentional, not a leak.
237+ capture(Effect.gen(function* () {
250238 const ext = yield* McpExtensionService;
251− const result = yield* ext
252− .completeOAuth({
239+ const result = yield* Effect.matchCauseEffect(
240+ ext.completeOAuth({
253241 state: urlParams.state,
254242 code: urlParams.code,
255243 error: urlParams.error ?? urlParams.error_description,
256− })
257− .pipe(
258− Effect.map(
259− (c): OAuthPopupResult => ({
244+ }),
245+ {
246+ onSuccess: (c) =>
247+ Effect.succeed<OAuthPopupResult>({
260248 type: "executor:oauth-result",
261249 ok: true,
262250 sessionId: urlParams.state,
@@ -266,17 +254,16 @@ export const McpHandlers = HttpApiBuilder.group(ExecutorApiWithMcp, "mcp", (hand · 3 unmodified lines
266254 expiresAt: c.expiresAt,
267255 scope: c.scope,
268256 }),
269− ),
270− Effect.catchAllCause((cause) =>
257+ onFailure: (cause) =>
271258 Effect.succeed<OAuthPopupResult>({
272259 type: "executor:oauth-result",
273260 ok: false,
274261 sessionId: null,
275− error: toPopupErrorMessage(Cause.squash(cause)),
262+ error: toPopupErrorMessage(cause),
276263 }),
277− ),
278− );
264+ },
265+ );
279266 return yield* HttpServerResponse.html(popupDocument(result));
280− }).pipe(sanitizeMcpFailure),
267+ })),
281268 ),
282269 );
packages/plugins/mcp/src/sdk/binding-store.ts+24−12
@@ -5,7 +5,11 @@ · 4 unmodified lines
55
66 import { Effect, Schema } from "effect";
77
8−import { defineSchema, type StorageDeps } from "@executor/sdk";
8+import {
9+ defineSchema,
10+ type StorageDeps,
11+ type StorageFailure,
12+} from "@executor/sdk";
913
1014 import { McpToolBinding, McpStoredSourceData } from "./types";
1115 import { McpOAuthSession } from "./oauth";
@@ -91,37 +95,45 @@ export interface McpStoredSource { · 79 unmodified lines
9195 // Store interface
9296 // ---------------------------------------------------------------------------
9397
98+// Every method routes through the typed adapter (`ctx.storage.adapter`)
99+// so the typed error channel is `StorageFailure`. Schema-decode failures
100+// inside `Effect.gen` land as defects, not typed errors, and are caught
101+// by the HTTP edge's observability middleware.
94102 export interface McpBindingStore {
95103 readonly getBinding: (
96104 toolId: string,
97105 ) => Effect.Effect<
98106 { readonly binding: McpToolBinding; readonly namespace: string } | null,
99− Error
107+ StorageFailure
100108 >;
101109
102110 readonly putBindings: (
103111 namespace: string,
104112 entries: ReadonlyArray<{ readonly toolId: string; readonly binding: McpToolBinding }>,
105− ) => Effect.Effect<void, Error>;
113+ ) => Effect.Effect<void, StorageFailure>;
106114
107− readonly removeBindingsByNamespace: (namespace: string) => Effect.Effect<void, Error>;
115+ readonly removeBindingsByNamespace: (
116+ namespace: string,
117+ ) => Effect.Effect<void, StorageFailure>;
108118
109− readonly listSources: () => Effect.Effect<readonly McpStoredSource[], Error>;
110− readonly getSource: (namespace: string) => Effect.Effect<McpStoredSource | null, Error>;
119+ readonly listSources: () => Effect.Effect<readonly McpStoredSource[], StorageFailure>;
120+ readonly getSource: (
121+ namespace: string,
122+ ) => Effect.Effect<McpStoredSource | null, StorageFailure>;
111123 readonly getSourceConfig: (
112124 namespace: string,
113− ) => Effect.Effect<McpStoredSourceData | null, Error>;
114− readonly putSource: (source: McpStoredSource) => Effect.Effect<void, Error>;
115− readonly removeSource: (namespace: string) => Effect.Effect<void, Error>;
125+ ) => Effect.Effect<McpStoredSourceData | null, StorageFailure>;
126+ readonly putSource: (source: McpStoredSource) => Effect.Effect<void, StorageFailure>;
127+ readonly removeSource: (namespace: string) => Effect.Effect<void, StorageFailure>;
116128
117129 readonly putOAuthSession: (
118130 sessionId: string,
119131 session: McpOAuthSession,
120− ) => Effect.Effect<void, Error>;
132+ ) => Effect.Effect<void, StorageFailure>;
121133 readonly getOAuthSession: (
122134 sessionId: string,
123− ) => Effect.Effect<McpOAuthSession | null, Error>;
124− readonly deleteOAuthSession: (sessionId: string) => Effect.Effect<void, Error>;
135+ ) => Effect.Effect<McpOAuthSession | null, StorageFailure>;
136+ readonly deleteOAuthSession: (sessionId: string) => Effect.Effect<void, StorageFailure>;
125137 }
126138
127139 // ---------------------------------------------------------------------------
packages/plugins/mcp/src/sdk/errors.ts+16−3
@@ -1,4 +1,10 @@
1+// MCP plugin tagged errors. Each carries an `HttpApiSchema` annotation so
2+// it can be `.addError(...)` directly on the API group — handlers return
3+// these and HttpApi encodes them as 4xx responses with a typed body. No
4+// per-handler sanitisation step.
5+
16 import { Schema } from "effect";
7+import { HttpApiSchema } from "@effect/platform";
28
39 export class McpConnectionError extends Schema.TaggedError<McpConnectionError>()(
410 "McpConnectionError",
@@ -6,6 +12,7 @@ export class McpConnectionError extends Schema.TaggedError<McpConnectionError>() · 1 unmodified line
612 transport: Schema.String,
713 message: Schema.String,
814 },
15+ HttpApiSchema.annotations({ status: 400 }),
916 ) {}
1017
1118 export class McpToolDiscoveryError extends Schema.TaggedError<McpToolDiscoveryError>()(
@@ -14,6 +21,7 @@ export class McpToolDiscoveryError extends Schema.TaggedError<McpToolDiscoveryEr · 2 unmodified lines
1421 stage: Schema.Literal("connect", "list_tools"),
1522 message: Schema.String,
1623 },
24+ HttpApiSchema.annotations({ status: 400 }),
1725 ) {}
1826
1927 export class McpInvocationError extends Schema.TaggedError<McpInvocationError>()(
@@ -22,8 +30,13 @@ export class McpInvocationError extends Schema.TaggedError<McpInvocationError>() · 2 unmodified lines
2230 toolName: Schema.String,
2331 message: Schema.String,
2432 },
33+ HttpApiSchema.annotations({ status: 400 }),
2534 ) {}
2635
27−export class McpOAuthError extends Schema.TaggedError<McpOAuthError>()("McpOAuthError", {
28− message: Schema.String,
29−}) {}
36+export class McpOAuthError extends Schema.TaggedError<McpOAuthError>()(
37+ "McpOAuthError",
38+ {
39+ message: Schema.String,
40+ },
41+ HttpApiSchema.annotations({ status: 400 }),
42+) {}
packages/plugins/mcp/src/sdk/invoke.ts+9−26
@@ -21,7 +21,7 @@ import { · 20 unmodified lines
2121 type ElicitationRequest,
2222 } from "@executor/sdk";
2323
24−import { McpConnectionError } from "./errors";
24+import { McpConnectionError, McpInvocationError } from "./errors";
2525 import type { McpConnection } from "./connection";
2626 import type { McpStoredSourceData } from "./types";
2727
@@ -124,17 +124,18 @@ const useConnection = ( · 96 unmodified lines
124124 toolName: string,
125125 args: Record<string, unknown>,
126126 elicit: Elicit,
127−): Effect.Effect<unknown, Error> =>
127+): Effect.Effect<unknown, McpInvocationError> =>
128128 Effect.gen(function* () {
129129 installElicitationHandler(connection.client, elicit);
130130 return yield* Effect.tryPromise({
131131 try: () => connection.client.callTool({ name: toolName, arguments: args }),
132132 catch: (cause) =>
133− new Error(
134− `MCP tool call failed for ${toolName}: ${
133+ new McpInvocationError({
134+ toolName,
135+ message: `MCP tool call failed for ${toolName}: ${
135136 cause instanceof Error ? cause.message : String(cause)
136137 }`,
137− ),
138+ }),
138139 });
139140 });
140141
@@ -162,7 +163,7 @@ export interface InvokeMcpToolInput { · 21 unmodified lines
162163
163164 export const invokeMcpTool = (
164165 input: InvokeMcpToolInput,
165−): Effect.Effect<unknown, Error> =>
166+): Effect.Effect<unknown, McpConnectionError | McpInvocationError> =>
166167 Effect.gen(function* () {
167168 const cacheKey = connectionCacheKey(input.sourceData);
168169 const args = asRecord(input.args);
@@ -172,16 +173,7 @@ export const invokeMcpTool = ( · 3 unmodified lines
172173 const connector = input.resolveConnector();
173174 input.pendingConnectors.set(cacheKey, connector);
174175
175− const firstConnection = yield* input.connectionCache.get(cacheKey).pipe(
176− Effect.mapError(
177− (err) =>
178− new Error(
179− `Failed connecting to MCP server: ${
180− err instanceof Error ? err.message : String(err)
181− }`,
182− ),
183− ),
184− );
176+ const firstConnection = yield* input.connectionCache.get(cacheKey);
185177
186178 return yield* useConnection(
187179 firstConnection,
@@ -195,16 +187,7 @@ export const invokeMcpTool = ( · 7 unmodified lines
195187 Effect.gen(function* () {
196188 yield* input.connectionCache.invalidate(cacheKey);
197189 input.pendingConnectors.set(cacheKey, connector);
198− const fresh = yield* input.connectionCache.get(cacheKey).pipe(
199− Effect.mapError(
200− (err) =>
201− new Error(
202− `Failed reconnecting to MCP server: ${
203− err instanceof Error ? err.message : String(err)
204− }`,
205− ),
206− ),
207− );
190+ const fresh = yield* input.connectionCache.get(cacheKey);
208191 return yield* useConnection(
209192 fresh,
210193 input.toolName,
packages/plugins/mcp/src/sdk/plugin.ts+34−12
@@ -9,6 +9,7 @@ import { · 8 unmodified lines
99 SetSecretInput,
1010 SourceDetectionResult,
1111 type PluginCtx,
12+ type StorageFailure,
1213 } from "@executor/sdk";
1314
1415 import {
@@ -211,7 +212,7 @@ const resolveConnectorInput = ( · 196 unmodified lines
211212 sd: McpStoredSourceData,
212213 ctx: PluginCtx<McpBindingStore>,
213214 allowStdio: boolean,
214−): Effect.Effect<ConnectorInput, Error> => {
215+): Effect.Effect<ConnectorInput, McpConnectionError | StorageFailure> => {
215216 if (sd.transport === "stdio") {
216217 if (!allowStdio) {
217218 return Effect.fail(
@@ -291,7 +292,7 @@ interface McpRuntime { · 73 unmodified lines
291292 readonly cacheScope: Scope.CloseableScope;
292293 }
293294
294−const makeRuntime = (): Effect.Effect<McpRuntime, Error> =>
295+const makeRuntime = (): Effect.Effect<McpRuntime, never> =>
295296 Effect.gen(function* () {
296297 const cacheScope = yield* Scope.make();
297298 const pendingConnectors = new Map<
@@ -403,7 +404,7 @@ export const mcpPlugin = definePlugin( · 105 unmodified lines
403404 // connection cache across a single createExecutor lifecycle.
404405 const runtimeRef: { current: McpRuntime | null } = { current: null };
405406
406− const ensureRuntime = (): Effect.Effect<McpRuntime, Error> =>
407+ const ensureRuntime = (): Effect.Effect<McpRuntime, never> =>
407408 runtimeRef.current
408409 ? Effect.succeed(runtimeRef.current)
409410 : makeRuntime().pipe(
@@ -796,7 +797,7 @@ export const mcpPlugin = definePlugin( · 386 unmodified lines
796797 completeOAuth,
797798 getSource,
798799 updateSource,
799− };
800+ } satisfies McpPluginExtension;
800801 },
801802
802803 invokeTool: ({ ctx, toolRow, args, elicit }) =>
@@ -938,28 +939,49 @@ export const mcpPlugin = definePlugin( · 135 unmodified lines
938939 // returns above.
939940 // ---------------------------------------------------------------------------
940941
942+/**
943+ * Errors any MCP extension method may surface. The first four are
944+ * plugin-domain tagged errors that flow directly to clients (4xx, each
945+ * carrying its own `HttpApiSchema` status). `StorageFailure` covers
946+ * raw backend failures (`StorageError`) plus `UniqueViolationError`;
947+ * the HTTP edge (`@executor/api`'s `withCapture`) translates
948+ * `StorageError` to the opaque `InternalError({ traceId })` at Layer
949+ * composition. `UniqueViolationError` passes through — plugins can
950+ * `Effect.catchTag` it if they want a friendlier user-facing error.
951+ */
952+export type McpExtensionFailure =
953+ | McpOAuthError
954+ | McpConnectionError
955+ | McpToolDiscoveryError
956+ | StorageFailure;
957+
941958 export interface McpPluginExtension {
942959 readonly probeEndpoint: (
943960 endpoint: string,
944− ) => Effect.Effect<McpProbeResult, Error>;
961+ ) => Effect.Effect<McpProbeResult, McpExtensionFailure>;
945962 readonly addSource: (
946963 config: McpSourceConfig,
947− ) => Effect.Effect<{ readonly toolCount: number; readonly namespace: string }, Error>;
948− readonly removeSource: (namespace: string) => Effect.Effect<void, Error>;
964+ ) => Effect.Effect<
965+ { readonly toolCount: number; readonly namespace: string },
966+ McpExtensionFailure
967+ >;
968+ readonly removeSource: (
969+ namespace: string,
970+ ) => Effect.Effect<void, McpExtensionFailure>;
949971 readonly refreshSource: (
950972 namespace: string,
951− ) => Effect.Effect<{ readonly toolCount: number }, Error>;
973+ ) => Effect.Effect<{ readonly toolCount: number }, McpExtensionFailure>;
952974 readonly startOAuth: (
953975 input: McpOAuthStartInput,
954− ) => Effect.Effect<McpOAuthStartResponse, Error>;
976+ ) => Effect.Effect<McpOAuthStartResponse, McpExtensionFailure>;
955977 readonly completeOAuth: (
956978 input: McpOAuthCompleteInput,
957− ) => Effect.Effect<McpOAuthCompleteResponse, Error>;
979+ ) => Effect.Effect<McpOAuthCompleteResponse, McpExtensionFailure>;
958980 readonly getSource: (
959981 namespace: string,
960− ) => Effect.Effect<McpStoredSource | null, Error>;
982+ ) => Effect.Effect<McpStoredSource | null, McpExtensionFailure>;
961983 readonly updateSource: (
962984 namespace: string,
963985 input: McpUpdateSourceInput,
964− ) => Effect.Effect<void, Error>;
986+ ) => Effect.Effect<void, McpExtensionFailure>;
965987 }
packages/plugins/onepassword/src/api/group.ts+19−14
@@ -1,6 +1,7 @@
11 import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "@effect/platform";
22 import { Schema } from "effect";
33 import { ScopeId } from "@executor/sdk";
4+import { InternalError } from "@executor/api";
45
56 import { OnePasswordError } from "../sdk/errors";
67 import { OnePasswordConfig, Vault, ConnectionStatus } from "../sdk/types";
@@ -32,14 +33,18 @@ const ListVaultsResponse = Schema.Struct({ · 25 unmodified lines
3233
3334 const GetConfigResponse = Schema.NullOr(OnePasswordConfig);
3435
35−// ---------------------------------------------------------------------------
36−// Errors with HTTP status
37−// ---------------------------------------------------------------------------
38−
39−const OpError = OnePasswordError.annotations(HttpApiSchema.annotations({ status: 502 }));
40−
4136 // ---------------------------------------------------------------------------
4237 // Group
38+//
39+// Plugin SDK errors (OnePasswordError) are declared once at the group level
40+// via `.addError(...)` — every endpoint inherits. The error carries its own
41+// 502 status via `HttpApiSchema.annotations` in errors.ts.
42+//
43+// `InternalError` is the shared opaque 500 schema translated at the HTTP
44+// edge by `withCapture` (see observability.ts). Storage failures on
45+// `ctx.storage`/`ctx.secrets` flow through as `StorageFailure` in the
46+// typed channel and are captured + downgraded to `InternalError({ traceId })`
47+// at Layer composition. No per-handler translation.
4348 // ---------------------------------------------------------------------------
4449
4550 export class OnePasswordGroup extends HttpApiGroup.make("onepassword")
@@ -51,8 +56,7 @@ export class OnePasswordGroup extends HttpApiGroup.make("onepassword") · 5 unmodified lines
5156 .add(
5257 HttpApiEndpoint.put("configure")`/scopes/${scopeIdParam}/onepassword/config`
5358 .setPayload(ConfigurePayload)
54− .addSuccess(Schema.Void)
55− .addError(OpError),
59+ .addSuccess(Schema.Void),
5660 )
5761 .add(
5862 HttpApiEndpoint.del("removeConfig")`/scopes/${scopeIdParam}/onepassword/config`.addSuccess(
@@ -60,13 +64,14 @@ export class OnePasswordGroup extends HttpApiGroup.make("onepassword") · 1 unmodified line
6064 ),
6165 )
6266 .add(
63− HttpApiEndpoint.get("status")`/scopes/${scopeIdParam}/onepassword/status`
64− .addSuccess(ConnectionStatus)
65− .addError(OpError),
67+ HttpApiEndpoint.get("status")`/scopes/${scopeIdParam}/onepassword/status`.addSuccess(
68+ ConnectionStatus,
69+ ),
6670 )
6771 .add(
6872 HttpApiEndpoint.get("listVaults")`/scopes/${scopeIdParam}/onepassword/vaults`
6973 .setUrlParams(ListVaultsParams)
70− .addSuccess(ListVaultsResponse)
71− .addError(OpError),
72− ) {}
74+ .addSuccess(ListVaultsResponse),
75+ )
76+ .addError(InternalError)
77+ .addError(OnePasswordError) {}
packages/plugins/onepassword/src/api/handlers.ts+25−12
@@ -1,12 +1,19 @@
11 import { HttpApiBuilder } from "@effect/platform";
22 import { Context, Effect } from "effect";
33
4−import { addGroup } from "@executor/api";
4+import { addGroup, capture } from "@executor/api";
55 import type { OnePasswordExtension } from "../sdk/plugin";
66 import { OnePasswordGroup } from "./group";
77
88 // ---------------------------------------------------------------------------
9−// Service tag — the server provides the 1Password extension
9+// Service tag
10+//
11+// Holds the `Captured` shape — every method's `StorageFailure` channel has
12+// been swapped for `InternalError({ traceId })`. The host provides an
13+// already-wrapped extension via
14+// `Layer.succeed(OnePasswordExtensionService, withCapture(executor).onepassword)`.
15+// Handlers see `InternalError` in the error union, which matches
16+// `.addError(InternalError)` on the group — no per-handler translation.
1017 // ---------------------------------------------------------------------------
1118
1219 export class OnePasswordExtensionService extends Context.Tag("OnePasswordExtensionService")<
@@ -22,6 +29,12 @@ const ExecutorApiWithOnePassword = addGroup(OnePasswordGroup); · 9 unmodified lines
2229
2330 // ---------------------------------------------------------------------------
2431 // Handlers
32+//
33+// Each handler is exactly: yield the extension service, call the method,
34+// return. Plugin SDK errors flow through the typed channel and are
35+// schema-encoded (OnePasswordError -> 502) by HttpApi. Defects bubble up
36+// and are captured + downgraded to `InternalError(traceId)` by the
37+// observability middleware.
2538 // ---------------------------------------------------------------------------
2639
2740 export const OnePasswordHandlers = HttpApiBuilder.group(
@@ -30,31 +43,31 @@ export const OnePasswordHandlers = HttpApiBuilder.group( · 2 unmodified lines
3043 (handlers) =>
3144 handlers
3245 .handle("getConfig", () =>
33− Effect.gen(function* () {
46+ capture(Effect.gen(function* () {
3447 const ext = yield* OnePasswordExtensionService;
3548 return yield* ext.getConfig();
36− }).pipe(Effect.orDie),
49+ })),
3750 )
3851 .handle("configure", ({ payload }) =>
39− Effect.gen(function* () {
52+ capture(Effect.gen(function* () {
4053 const ext = yield* OnePasswordExtensionService;
4154 yield* ext.configure(payload);
42− }).pipe(Effect.orDie),
55+ })),
4356 )
4457 .handle("removeConfig", () =>
45− Effect.gen(function* () {
58+ capture(Effect.gen(function* () {
4659 const ext = yield* OnePasswordExtensionService;
4760 yield* ext.removeConfig();
48− }).pipe(Effect.orDie),
61+ })),
4962 )
5063 .handle("status", () =>
51− Effect.gen(function* () {
64+ capture(Effect.gen(function* () {
5265 const ext = yield* OnePasswordExtensionService;
5366 return yield* ext.status();
54− }).pipe(Effect.orDie),
67+ })),
5568 )
5669 .handle("listVaults", ({ urlParams }) =>
57− Effect.gen(function* () {
70+ capture(Effect.gen(function* () {
5871 const ext = yield* OnePasswordExtensionService;
5972 const auth =
6073 urlParams.authKind === "desktop-app"
@@ -62,6 +75,6 @@ export const OnePasswordHandlers = HttpApiBuilder.group( · 1 unmodified line
6275 : { kind: "service-account" as const, tokenSecretId: urlParams.account };
6376 const vaults = yield* ext.listVaults(auth);
6477 return { vaults: [...vaults] };
65− }).pipe(Effect.orDie),
78+ })),
6679 ),
6780 );
packages/plugins/onepassword/src/sdk/errors.ts+9−4
@@ -1,6 +1,11 @@
11 import { Schema } from "effect";
2+import { HttpApiSchema } from "@effect/platform";
23
3−export class OnePasswordError extends Schema.TaggedError<OnePasswordError>()("OnePasswordError", {
4− operation: Schema.String,
5− message: Schema.String,
6−}) {}
4+export class OnePasswordError extends Schema.TaggedError<OnePasswordError>()(
5+ "OnePasswordError",
6+ {
7+ operation: Schema.String,
8+ message: Schema.String,
9+ },
10+ HttpApiSchema.annotations({ status: 502 }),
11+) {}
packages/plugins/onepassword/src/sdk/plugin.ts+108−103
@@ -2,9 +2,11 @@ import { Effect, Schema } from "effect"; · 1 unmodified line
22
33 import {
44 definePlugin,
5+ StorageError,
56 type PluginCtx,
67 type ScopedBlobStore,
78 type SecretProvider,
9+ type StorageFailure,
810 } from "@executor/sdk";
911
1012 import { OnePasswordConfig, Vault, ConnectionStatus } from "./types";
@@ -24,6 +26,20 @@ const CREDENTIAL_FIELD = "credential"; · 13 unmodified lines
2426 const DEFAULT_TIMEOUT_MS = 15_000;
2527 const CONFIG_KEY = "config";
2628
29+// ---------------------------------------------------------------------------
30+// Shared failure alias.
31+//
32+// Every extension method either touches storage (`ctx.storage` blobs or
33+// `ctx.secrets`) or reaches the 1Password backend. Storage I/O surfaces
34+// as `StorageFailure`; the HTTP edge (`withCapture`) translates
35+// `StorageError` to `InternalError({ traceId })`. Domain problems (not
36+// configured, service-account token missing, backend RPC failure) stay
37+// as `OnePasswordError` and encode to 502 via the schema annotation on
38+// the class.
39+// ---------------------------------------------------------------------------
40+
41+export type OnePasswordExtensionFailure = OnePasswordError | StorageFailure;
42+
2743 // ---------------------------------------------------------------------------
2844 // Plugin extension — public API on executor.onepassword
2945 // ---------------------------------------------------------------------------
@@ -32,59 +48,69 @@ export interface OnePasswordExtension { · 2 unmodified lines
3248 /** Configure the 1Password connection */
3349 readonly configure: (
3450 config: OnePasswordConfig,
35− ) => Effect.Effect<void, OnePasswordError>;
51+ ) => Effect.Effect<void, StorageFailure>;
3652
3753 /** Get current configuration (if any) */
3854 readonly getConfig: () => Effect.Effect<
3955 OnePasswordConfig | null,
40− OnePasswordError
56+ OnePasswordExtensionFailure
4157 >;
4258
4359 /** Remove the 1Password configuration */
44− readonly removeConfig: () => Effect.Effect<void, OnePasswordError>;
60+ readonly removeConfig: () => Effect.Effect<void, StorageFailure>;
4561
4662 /** Check connection status */
47− readonly status: () => Effect.Effect<ConnectionStatus, OnePasswordError>;
63+ readonly status: () => Effect.Effect<
64+ ConnectionStatus,
65+ OnePasswordExtensionFailure
66+ >;
4867
4968 /** List accessible vaults (requires auth) */
5069 readonly listVaults: (
5170 auth: OnePasswordAuth,
52− ) => Effect.Effect<ReadonlyArray<Vault>, OnePasswordError>;
71+ ) => Effect.Effect<ReadonlyArray<Vault>, OnePasswordExtensionFailure>;
5372
5473 /** Resolve a secret directly by op:// URI */
55− readonly resolve: (uri: string) => Effect.Effect<string, OnePasswordError>;
74+ readonly resolve: (
75+ uri: string,
76+ ) => Effect.Effect<string, OnePasswordExtensionFailure>;
5677 }
5778
5879 // ---------------------------------------------------------------------------
59−// Typed config store — single blob, JSON encoded. Declared inline because
60−// there's only one key and one document; a schema table would be overkill.
80+// Typed config store — single blob, JSON encoded. Blob I/O failures surface
81+// as `StorageError` (HTTP edge translates to `InternalError`); decode
82+// failures stay `OnePasswordError` — the blob's contents are a plugin
83+// concern, not an infrastructure one.
6184 // ---------------------------------------------------------------------------
6285
6386 export interface OnePasswordStore {
6487 readonly getConfig: () => Effect.Effect<
6588 OnePasswordConfig | null,
66− OnePasswordError
89+ StorageError | OnePasswordError
6790 >;
6891 readonly saveConfig: (
6992 config: OnePasswordConfig,
70− ) => Effect.Effect<void, OnePasswordError>;
71− readonly deleteConfig: () => Effect.Effect<void, OnePasswordError>;
93+ ) => Effect.Effect<void, StorageError>;
94+ readonly deleteConfig: () => Effect.Effect<void, StorageError>;
7295 }
7396
7497 const decodeConfig = Schema.decodeUnknownSync(OnePasswordConfig);
7598
99+const blobStorageError = (operation: string) =>
100+ (cause: unknown): StorageError =>
101+ new StorageError({
102+ message: `onepassword blob ${operation}: ${
103+ cause instanceof Error ? cause.message : String(cause)
104+ }`,
105+ cause,
106+ });
107+
76108 export const makeOnePasswordStore = (
77109 blobs: ScopedBlobStore,
78110 ): OnePasswordStore => ({
79111 getConfig: () =>
80112 blobs.get(CONFIG_KEY).pipe(
81− Effect.mapError(
82− (cause) =>
83− new OnePasswordError({
84− operation: "config load",
85− message: cause instanceof Error ? cause.message : String(cause),
86− }),
87− ),
113+ Effect.mapError(blobStorageError("read")),
88114 Effect.flatMap((raw) => {
89115 if (raw === null) return Effect.succeed(null);
90116 return Effect.try({
@@ -108,26 +134,10 @@ export const makeOnePasswordStore = ( · 17 unmodified lines
108134 name: config.name,
109135 }),
110136 )
111− .pipe(
112− Effect.mapError(
113− (cause) =>
114− new OnePasswordError({
115− operation: "config save",
116− message: cause instanceof Error ? cause.message : String(cause),
117− }),
118− ),
119− ),
137+ .pipe(Effect.mapError(blobStorageError("write"))),
120138
121139 deleteConfig: () =>
122− blobs.delete(CONFIG_KEY).pipe(
123− Effect.mapError(
124− (cause) =>
125− new OnePasswordError({
126− operation: "config delete",
127− message: cause instanceof Error ? cause.message : String(cause),
128− }),
129− ),
130− ),
140+ blobs.delete(CONFIG_KEY).pipe(Effect.mapError(blobStorageError("delete"))),
131141 });
132142
133143 // ---------------------------------------------------------------------------
@@ -137,7 +147,7 @@ export const makeOnePasswordStore = ( · 3 unmodified lines
137147 const resolveAuth = (
138148 auth: OnePasswordAuth,
139149 ctx: PluginCtx<OnePasswordStore>,
140−): Effect.Effect<ResolvedAuth, OnePasswordError> => {
150+): Effect.Effect<ResolvedAuth, OnePasswordError | StorageFailure> => {
141151 if (auth.kind === "desktop-app") {
142152 return Effect.succeed({
143153 kind: "desktop-app" as const,
@@ -145,13 +155,6 @@ const resolveAuth = ( · 1 unmodified line
145155 });
146156 }
147157 return ctx.secrets.get(auth.tokenSecretId).pipe(
148− Effect.mapError(
149− (cause) =>
150− new OnePasswordError({
151− operation: "auth resolution",
152− message: `Failed to resolve service account token secret "${auth.tokenSecretId}": ${cause instanceof Error ? cause.message : String(cause)}`,
153− }),
154− ),
155158 Effect.flatMap((token) => {
156159 if (token === null) {
157160 return Effect.fail(
@@ -174,7 +177,7 @@ const getServiceFromConfig = ( · 16 unmodified lines
174177 ctx: PluginCtx<OnePasswordStore>,
175178 timeoutMs: number,
176179 preferSdk: boolean | undefined,
177−): Effect.Effect<OnePasswordService, OnePasswordError> =>
180+): Effect.Effect<OnePasswordService, OnePasswordError | StorageFailure> =>
178181 resolveAuth(config.auth, ctx).pipe(
179182 Effect.flatMap((resolved) =>
180183 makeOnePasswordService(resolved, { timeoutMs, preferSdk }),
@@ -252,69 +255,71 @@ export const onepasswordPlugin = definePlugin( · 71 unmodified lines
252255 id: "onepassword" as const,
253256 storage: ({ blobs }) => makeOnePasswordStore(blobs),
254257
255− extension: (ctx): OnePasswordExtension => ({
256− configure: (config) => ctx.storage.saveConfig(config),
257−
258− getConfig: () => ctx.storage.getConfig(),
259−
260− removeConfig: () => ctx.storage.deleteConfig(),
261−
262− status: () =>
263− Effect.gen(function* () {
264− const config = yield* ctx.storage.getConfig();
265− if (!config) {
258+ extension: (ctx) => {
259+ return {
260+ configure: (config) => ctx.storage.saveConfig(config),
261+
262+ getConfig: () => ctx.storage.getConfig(),
263+
264+ removeConfig: () => ctx.storage.deleteConfig(),
265+
266+ status: () =>
267+ Effect.gen(function* () {
268+ const config = yield* ctx.storage.getConfig();
269+ if (!config) {
270+ return new ConnectionStatus({
271+ connected: false,
272+ error: "Not configured",
273+ });
274+ }
275+ const svc = yield* getServiceFromConfig(
276+ config,
277+ ctx,
278+ timeoutMs,
279+ preferSdk,
280+ );
281+ const vaults = yield* svc.listVaults();
282+ const vault = vaults.find((v) => v.id === config.vaultId);
266283 return new ConnectionStatus({
267− connected: false,
268− error: "Not configured",
284+ connected: true,
285+ vaultName: vault?.title,
269286 });
270− }
271− const svc = yield* getServiceFromConfig(
272− config,
273− ctx,
274− timeoutMs,
275− preferSdk,
276− );
277− const vaults = yield* svc.listVaults();
278− const vault = vaults.find((v) => v.id === config.vaultId);
279− return new ConnectionStatus({
280− connected: true,
281− vaultName: vault?.title,
282− });
283− }),
287+ }),
284288
285− listVaults: (auth) =>
286− Effect.gen(function* () {
287− const resolved = yield* resolveAuth(auth, ctx);
288− const svc = yield* makeOnePasswordService(resolved, {
289− timeoutMs,
290− preferSdk,
291− });
292− const vaults = yield* svc.listVaults();
293− return vaults
294− .map((v) => new Vault({ id: v.id, name: v.title }))
295− .sort((a, b) => a.name.localeCompare(b.name));
296− }),
289+ listVaults: (auth) =>
290+ Effect.gen(function* () {
291+ const resolved = yield* resolveAuth(auth, ctx);
292+ const svc = yield* makeOnePasswordService(resolved, {
293+ timeoutMs,
294+ preferSdk,
295+ });
296+ const vaults = yield* svc.listVaults();
297+ return vaults
298+ .map((v) => new Vault({ id: v.id, name: v.title }))
299+ .sort((a, b) => a.name.localeCompare(b.name));
300+ }),
297301
298− resolve: (uri) =>
299− Effect.gen(function* () {
300− const config = yield* ctx.storage.getConfig();
301− if (!config) {
302− return yield* Effect.fail(
303− new OnePasswordError({
304− operation: "resolve",
305− message: "1Password is not configured",
306− }),
302+ resolve: (uri) =>
303+ Effect.gen(function* () {
304+ const config = yield* ctx.storage.getConfig();
305+ if (!config) {
306+ return yield* Effect.fail(
307+ new OnePasswordError({
308+ operation: "resolve",
309+ message: "1Password is not configured",
310+ }),
311+ );
312+ }
313+ const svc = yield* getServiceFromConfig(
314+ config,
315+ ctx,
316+ timeoutMs,
317+ preferSdk,
307318 );
308− }
309− const svc = yield* getServiceFromConfig(
310− config,
311− ctx,
312− timeoutMs,
313− preferSdk,
314− );
315− return yield* svc.resolveSecret(uri);
316− }),
317− }),
319+ return yield* svc.resolveSecret(uri);
320+ }),
321+ } satisfies OnePasswordExtension;
322+ },
318323
319324 secretProviders: (ctx) => [makeProvider(ctx, timeoutMs, preferSdk)],
320325 };
packages/plugins/openapi/src/api/group.ts+30−28
@@ -1,6 +1,7 @@
11 import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "@effect/platform";
22 import { Schema } from "effect";
33 import { ScopeId } from "@executor/sdk";
4+import { InternalError } from "@executor/api";
45
56 import {
67 OpenApiParseError,
@@ -99,57 +100,52 @@ const ExtractionError = OpenApiExtractionError.annotations( · 92 unmodified lines
99100 );
100101 const OAuthError = OpenApiOAuthError.annotations(HttpApiSchema.annotations({ status: 400 }));
101102
102−export class OpenApiInternalError extends Schema.TaggedError<OpenApiInternalError>()(
103− "OpenApiInternalError",
104− {
105− message: Schema.String,
106− },
107− HttpApiSchema.annotations({ status: 500 }),
108−) {}
109−
110103 // ---------------------------------------------------------------------------
111104 // Group
105+//
106+// Plugin SDK errors (OpenApiParseError, OpenApiExtractionError,
107+// OpenApiOAuthError) are declared once at the group level via
108+// `.addError(...)` — every endpoint inherits them. The errors themselves
109+// carry their HTTP status via `HttpApiSchema.annotations` above, so
110+// handlers just `return yield* ext.foo(...)` and the schema encodes
111+// whatever comes out.
112+//
113+// 5xx is handled at the API level: `.addError(InternalError)` adds the
114+// shared opaque 500 surface. Defects are captured + downgraded to it by
115+// an HttpApiBuilder middleware (see apps/cloud/src/observability.ts).
116+// StorageError → InternalError translation happens at service wiring
117+// time via `withCapture(executor)`.
112118 // ---------------------------------------------------------------------------
113119
114120 export class OpenApiGroup extends HttpApiGroup.make("openapi")
115121 .add(
116122 HttpApiEndpoint.post("previewSpec")`/scopes/${scopeIdParam}/openapi/preview`
117123 .setPayload(PreviewSpecPayload)
118− .addSuccess(SpecPreview)
119− .addError(ParseError)
120− .addError(ExtractionError)
121− .addError(OpenApiInternalError),
124+ .addSuccess(SpecPreview),
122125 )
123126 .add(
124127 HttpApiEndpoint.post("addSpec")`/scopes/${scopeIdParam}/openapi/specs`
125128 .setPayload(AddSpecPayload)
126− .addSuccess(AddSpecResponse)
127− .addError(ParseError)
128− .addError(ExtractionError)
129− .addError(OpenApiInternalError),
129+ .addSuccess(AddSpecResponse),
130130 )
131131 .add(
132132 HttpApiEndpoint.get("getSource")`/scopes/${scopeIdParam}/openapi/sources/${namespaceParam}`
133− .addSuccess(Schema.NullOr(StoredSourceSchema))
134− .addError(OpenApiInternalError),
133+ .addSuccess(Schema.NullOr(StoredSourceSchema)),
135134 )
136135 .add(
137136 HttpApiEndpoint.patch("updateSource")`/scopes/${scopeIdParam}/openapi/sources/${namespaceParam}`
138137 .setPayload(UpdateSourcePayload)
139− .addSuccess(UpdateSourceResponse)
140− .addError(OpenApiInternalError),
138+ .addSuccess(UpdateSourceResponse),
141139 )
142140 .add(
143141 HttpApiEndpoint.post("startOAuth")`/scopes/${scopeIdParam}/openapi/oauth/start`
144142 .setPayload(StartOAuthPayload)
145− .addSuccess(StartOAuthResponse)
146− .addError(OAuthError),
143+ .addSuccess(StartOAuthResponse),
147144 )
148145 .add(
149146 HttpApiEndpoint.post("completeOAuth")`/scopes/${scopeIdParam}/openapi/oauth/complete`
150147 .setPayload(CompleteOAuthPayload)
151− .addSuccess(OAuth2Auth)
152− .addError(OAuthError),
148+ .addSuccess(OAuth2Auth),
153149 )
154150 .add(
155151 HttpApiEndpoint.get("oauthCallback", "/openapi/oauth/callback")
@@ -158,7 +154,13 @@ export class OpenApiGroup extends HttpApiGroup.make("openapi") · 2 unmodified lines
158154 Schema.Unknown.annotations(
159155 HttpApiSchema.annotations({ contentType: "text/html" }),
160156 ),
161− )
162− .addError(OAuthError)
163− .addError(OpenApiInternalError),
164− ) {}
157+ ),
158+ )
159+ // Errors declared once at the group level — every endpoint inherits.
160+ // Plugin domain errors carry their own HttpApiSchema status (4xx);
161+ // `InternalError` is the shared opaque 500 translated at the HTTP
162+ // edge by `withCapture`.
163+ .addError(InternalError)
164+ .addError(ParseError)
165+ .addError(ExtractionError)
166+ .addError(OAuthError) {}
packages/plugins/openapi/src/api/handlers.ts+36−60
@@ -1,21 +1,17 @@
11 import { HttpApiBuilder, HttpServerResponse } from "@effect/platform";
2−import { Cause, Context, Effect } from "effect";
2+import { Context, Effect } from "effect";
33
44 import { runOAuthCallback } from "@executor/plugin-oauth2/http";
55
6−import { addGroup } from "@executor/api";
7−import {
8− OpenApiExtractionError,
9− OpenApiOAuthError,
10− OpenApiParseError,
11−} from "../sdk/errors";
6+import { addGroup, capture, InternalError } from "@executor/api";
7+import { OpenApiOAuthError } from "../sdk/errors";
128 import type {
139 OpenApiPluginExtension,
1410 HeaderValue,
1511 OpenApiUpdateSourceInput,
1612 } from "../sdk/plugin";
1713 import { OAuth2Auth } from "../sdk/types";
18−import { OpenApiGroup, OpenApiInternalError } from "./group";
14+import { OpenApiGroup } from "./group";
1915
2016 const OPENAPI_OAUTH_CHANNEL = "executor:openapi-oauth-result";
2117
@@ -25,7 +21,14 @@ const toPopupErrorMessage = (error: unknown): string => { · 3 unmodified lines
2521 };
2622
2723 // ---------------------------------------------------------------------------
28−// Service tag — the server provides the OpenAPI extension
24+// Service tag
25+//
26+// Holds the `Captured` shape — every method's `StorageFailure`
27+// channel has been swapped for `InternalError({ traceId })`. The cloud
28+// app provides an already-wrapped extension via
29+// `Layer.succeed(OpenApiExtensionService, withCapture(executor.openapi))`.
30+// Handlers see `InternalError` in the error union, which matches
31+// `.addError(InternalError)` on the group — no per-handler translation.
2932 // ---------------------------------------------------------------------------
3033
3134 export class OpenApiExtensionService extends Context.Tag("OpenApiExtensionService")<
@@ -33,42 +36,6 @@ export class OpenApiExtensionService extends Context.Tag("OpenApiExtensionServic · 1 unmodified line
3336 OpenApiPluginExtension
3437 >() {}
3538
36−// ---------------------------------------------------------------------------
37−// Failure mapping
38−// ---------------------------------------------------------------------------
39−
40−type OpenApiSpecFailure = OpenApiParseError | OpenApiExtractionError | OpenApiInternalError;
41−
42−const toOpenApiSpecFailure = (error: unknown): OpenApiSpecFailure => {
43− if (
44− error instanceof OpenApiParseError ||
45− error instanceof OpenApiExtractionError ||
46− error instanceof OpenApiInternalError
47− ) {
48− return error;
49− }
50− const message = error instanceof Error ? error.message : String(error);
51− return new OpenApiInternalError({ message });
52−};
53−
54−const sanitizeSpecFailure = <A, R>(
55− effect: Effect.Effect<A, unknown, R>,
56−): Effect.Effect<A, OpenApiSpecFailure, R> =>
57− Effect.catchAllCause(effect, (cause) => Effect.fail(toOpenApiSpecFailure(Cause.squash(cause))));
58−
59−const toOpenApiInternalError = (error: unknown): OpenApiInternalError => {
60− if (error instanceof OpenApiInternalError) return error;
61− const message = error instanceof Error ? error.message : String(error);
62− return new OpenApiInternalError({ message });
63−};
64−
65−const sanitizeInternalFailure = <A, R>(
66− effect: Effect.Effect<A, unknown, R>,
67−): Effect.Effect<A, OpenApiInternalError, R> =>
68− Effect.catchAllCause(effect, (cause) =>
69− Effect.fail(toOpenApiInternalError(Cause.squash(cause))),
70− );
71−
7239 // ---------------------------------------------------------------------------
7340 // Composed API — core + openapi group
7441 // ---------------------------------------------------------------------------
@@ -77,18 +44,24 @@ const ExecutorApiWithOpenApi = addGroup(OpenApiGroup); · 2 unmodified lines
7744
7845 // ---------------------------------------------------------------------------
7946 // Handlers
47+//
48+// Each handler is exactly: yield the extension service, call the method,
49+// return. Plugin SDK errors flow through the typed channel and are
50+// schema-encoded to 4xx by HttpApi (see group.ts `.addError(...)` calls).
51+// Defects bubble up and are captured + downgraded to `InternalError(traceId)`
52+// by the API-level observability middleware.
8053 // ---------------------------------------------------------------------------
8154
8255 export const OpenApiHandlers = HttpApiBuilder.group(ExecutorApiWithOpenApi, "openapi", (handlers) =>
8356 handlers
8457 .handle("previewSpec", ({ payload }) =>
85− Effect.gen(function* () {
58+ capture(Effect.gen(function* () {
8659 const ext = yield* OpenApiExtensionService;
8760 return yield* ext.previewSpec(payload.spec);
88− }).pipe(sanitizeSpecFailure),
61+ })),
8962 )
9063 .handle("addSpec", ({ payload }) =>
91− Effect.gen(function* () {
64+ capture(Effect.gen(function* () {
9265 const ext = yield* OpenApiExtensionService;
9366 const result = yield* ext.addSpec({
9467 spec: payload.spec,
@@ -102,16 +75,16 @@ export const OpenApiHandlers = HttpApiBuilder.group(ExecutorApiWithOpenApi, "ope · 7 unmodified lines
10275 toolCount: result.toolCount,
10376 namespace: result.sourceId,
10477 };
105− }).pipe(sanitizeSpecFailure),
78+ })),
10679 )
10780 .handle("getSource", ({ path }) =>
108− Effect.gen(function* () {
81+ capture(Effect.gen(function* () {
10982 const ext = yield* OpenApiExtensionService;
11083 return yield* ext.getSource(path.namespace);
111− }).pipe(sanitizeInternalFailure),
84+ })),
11285 )
11386 .handle("updateSource", ({ path, payload }) =>
114− Effect.gen(function* () {
87+ capture(Effect.gen(function* () {
11588 const ext = yield* OpenApiExtensionService;
11689 yield* ext.updateSource(path.namespace, {
11790 name: payload.name,
@@ -119,10 +92,10 @@ export const OpenApiHandlers = HttpApiBuilder.group(ExecutorApiWithOpenApi, "ope · 1 unmodified line
11992 headers: payload.headers as Record<string, HeaderValue> | undefined,
12093 } as OpenApiUpdateSourceInput);
12194 return { updated: true };
122− }).pipe(sanitizeInternalFailure),
95+ })),
12396 )
12497 .handle("startOAuth", ({ payload }) =>
125− Effect.gen(function* () {
98+ capture(Effect.gen(function* () {
12699 const ext = yield* OpenApiExtensionService;
127100 return yield* ext.startOAuth({
128101 displayName: payload.displayName,
@@ -135,22 +108,25 @@ export const OpenApiHandlers = HttpApiBuilder.group(ExecutorApiWithOpenApi, "ope · 6 unmodified lines
135108 clientSecretSecretId: payload.clientSecretSecretId ?? null,
136109 scopes: [...payload.scopes],
137110 });
138− }),
111+ })),
139112 )
140113 .handle("completeOAuth", ({ payload }) =>
141− Effect.gen(function* () {
114+ capture(Effect.gen(function* () {
142115 const ext = yield* OpenApiExtensionService;
143116 return yield* ext.completeOAuth({
144117 state: payload.state,
145118 code: payload.code,
146119 error: payload.error,
147120 });
148− }),
121+ })),
149122 )
150123 .handle("oauthCallback", ({ urlParams }) =>
151− Effect.gen(function* () {
124+ // OAuth popup is special: it always returns 200 HTML and renders the
125+ // failure into the popup body so the parent window's listener gets a
126+ // structured result.
127+ capture(Effect.gen(function* () {
152128 const ext = yield* OpenApiExtensionService;
153− const html = yield* runOAuthCallback<OAuth2Auth, OpenApiOAuthError, never>({
129+ const html = yield* runOAuthCallback<OAuth2Auth, OpenApiOAuthError | InternalError, never>({
154130 complete: ({ state, code, error }) =>
155131 ext.completeOAuth({
156132 state,
@@ -162,6 +138,6 @@ export const OpenApiHandlers = HttpApiBuilder.group(ExecutorApiWithOpenApi, "ope · 5 unmodified lines
162138 channelName: OPENAPI_OAUTH_CHANNEL,
163139 });
164140 return yield* HttpServerResponse.html(html);
165− }).pipe(sanitizeInternalFailure),
141+ })),
166142 ),
167143 );
packages/plugins/openapi/src/sdk/invoke.ts+29−8
@@ -1,6 +1,8 @@
11 import { Effect, Layer, Option } from "effect";
22 import { HttpClient, HttpClientRequest } from "@effect/platform";
33
4+import type { StorageFailure } from "@executor/sdk";
5+
46 import { OpenApiInvocationError } from "./errors";
57 import {
68 type HeaderValue,
@@ -92,8 +94,15 @@ const resolvePath = Effect.fn("OpenApi.resolvePath")(function* ( · 85 unmodified lines
9294
9395 export const resolveHeaders = (
9496 headers: Record<string, HeaderValue>,
95− secrets: { readonly get: (id: string) => Effect.Effect<string | null, Error> },
96−): Effect.Effect<Record<string, string>, Error> =>
97+ secrets: {
98+ readonly get: (
99+ id: string,
100+ ) => Effect.Effect<string | null, StorageFailure>;
101+ },
102+): Effect.Effect<
103+ Record<string, string>,
104+ OpenApiInvocationError | StorageFailure
105+> =>
97106 Effect.gen(function* () {
98107 const entries = Object.entries(headers);
99108 // Fan out secret lookups: on every invocation, one or two headers
@@ -108,9 +117,10 @@ export const resolveHeaders = ( · 8 unmodified lines
108117 Effect.flatMap((secret) =>
109118 secret === null
110119 ? Effect.fail(
111− new Error(
112− `Failed to resolve secret "${value.secretId}" for header "${name}"`,
113− ),
120+ new OpenApiInvocationError({
121+ message: `Failed to resolve secret "${value.secretId}" for header "${name}"`,
122+ statusCode: Option.none(),
123+ }),
114124 )
115125 : Effect.succeed({
116126 name,
@@ -209,12 +219,23 @@ export const invoke = Effect.fn("OpenApi.invoke")(function* ( · 92 unmodified lines
209219 const responseHeaders: Record<string, string> = { ...response.headers };
210220
211221 const contentType = response.headers["content-type"] ?? null;
222+ const mapBodyError = Effect.mapError(
223+ (err: { readonly message?: string }) =>
224+ new OpenApiInvocationError({
225+ message: `Failed to read response body: ${err.message ?? String(err)}`,
226+ statusCode: Option.some(status),
227+ cause: err,
228+ }),
229+ );
212230 const responseBody: unknown =
213231 status === 204
214232 ? null
215233 : isJsonContentType(contentType)
216− ? yield* response.json.pipe(Effect.catchAll(() => response.text))
217− : yield* response.text;
234+ ? yield* response.json.pipe(
235+ Effect.catchAll(() => response.text),
236+ mapBodyError,
237+ )
238+ : yield* response.text.pipe(mapBodyError);
218239
219240 const ok = status >= 200 && status < 300;
220241
@@ -236,7 +257,7 @@ export const invokeWithLayer = ( · 15 unmodified lines
236257 baseUrl: string,
237258 resolvedHeaders: Record<string, string>,
238259 httpClientLayer: Layer.Layer<HttpClient.HttpClient>,
239−): Effect.Effect<InvocationResult, Error> => {
260+) => {
240261 const clientWithBaseUrl = baseUrl
241262 ? Layer.effect(
242263 HttpClient.HttpClient,
packages/plugins/openapi/src/sdk/plugin.ts+36−8
@@ -18,6 +18,7 @@ import { · 17 unmodified lines
1818 SetSecretInput,
1919 SourceDetectionResult,
2020 definePlugin,
21+ type StorageFailure,
2122 type ToolAnnotations,
2223 type ToolRow,
2324 } from "@executor/sdk";
@@ -28,7 +29,11 @@ import { · 4 unmodified lines
2829 type OpenApiSourceConfig,
2930 } from "@executor/config";
3031
31−import { OpenApiOAuthError } from "./errors";
32+import {
33+ OpenApiExtractionError,
34+ OpenApiOAuthError,
35+ OpenApiParseError,
36+} from "./errors";
3237 import { parse, resolveSpecText } from "./parse";
3338 import { extract } from "./extract";
3439 import { compileToolDefinitions, type ToolDefinition } from "./definitions";
@@ -105,17 +110,40 @@ export interface OpenApiCompleteOAuthInput { · 70 unmodified lines
105110 readonly error?: string;
106111 }
107112
113+/**
114+ * Errors any OpenAPI extension method may surface. The first three are
115+ * plugin-domain tagged errors that flow directly to clients (4xx, each
116+ * carrying its own `HttpApiSchema` status). `StorageFailure` covers
117+ * raw backend failures (`StorageError`) plus `UniqueViolationError`;
118+ * the HTTP edge (`@executor/api`'s `withCapture`) translates
119+ * `StorageError` to the opaque `InternalError({ traceId })` at Layer
120+ * composition. `UniqueViolationError` passes through — plugins can
121+ * `Effect.catchTag` it if they want a friendlier user-facing error.
122+ */
123+export type OpenApiExtensionFailure =
124+ | OpenApiParseError
125+ | OpenApiExtractionError
126+ | OpenApiOAuthError
127+ | StorageFailure;
128+
108129 export interface OpenApiPluginExtension {
109− readonly previewSpec: (specText: string) => Effect.Effect<SpecPreview, Error>;
130+ readonly previewSpec: (
131+ specText: string,
132+ ) => Effect.Effect<SpecPreview, OpenApiParseError | OpenApiExtractionError>;
110133 readonly addSpec: (
111134 config: OpenApiSpecConfig,
112− ) => Effect.Effect<{ readonly sourceId: string; readonly toolCount: number }, Error>;
113− readonly removeSpec: (namespace: string) => Effect.Effect<void, Error>;
114− readonly getSource: (namespace: string) => Effect.Effect<StoredSource | null, Error>;
135+ ) => Effect.Effect<
136+ { readonly sourceId: string; readonly toolCount: number },
137+ OpenApiParseError | OpenApiExtractionError | StorageFailure
138+ >;
139+ readonly removeSpec: (namespace: string) => Effect.Effect<void, StorageFailure>;
140+ readonly getSource: (
141+ namespace: string,
142+ ) => Effect.Effect<StoredSource | null, StorageFailure>;
115143 readonly updateSource: (
116144 namespace: string,
117145 input: OpenApiUpdateSourceInput,
118− ) => Effect.Effect<void, Error>;
146+ ) => Effect.Effect<void, StorageFailure>;
119147 readonly startOAuth: (
120148 input: OpenApiStartOAuthInput,
121149 ) => Effect.Effect<OpenApiStartOAuthResponse, OpenApiOAuthError>;
@@ -222,7 +250,7 @@ export const openApiPlugin = definePlugin( · 100 unmodified lines
222250 schema: openapiSchema,
223251 storage: (deps): OpenapiStore => makeDefaultOpenapiStore(deps),
224252
225− extension: (ctx): OpenApiPluginExtension => {
253+ extension: (ctx) => {
226254 // Wraps ctx.secrets.set for the oauth2 helper so createSecret
227255 // returns the freshly-minted id.
228256 const createOAuthSecret = (args: {
@@ -523,7 +551,7 @@ export const openApiPlugin = definePlugin( · 294 unmodified lines
523551 : new OpenApiOAuthError({ message: err.message }),
524552 ),
525553 ),
526− };
554+ } satisfies OpenApiPluginExtension;
527555 },
528556
529557 staticSources: (self) => [
packages/plugins/openapi/src/sdk/store.ts+19−11
@@ -1,6 +1,10 @@
11 import { Effect, Option, Schema } from "effect";
22
3−import { defineSchema, type StorageDeps } from "@executor/sdk";
3+import {
4+ defineSchema,
5+ type StorageDeps,
6+ type StorageFailure,
7+} from "@executor/sdk";
48
59 import {
610 HeaderValue,
@@ -134,11 +138,15 @@ const decodeHeaders = (value: unknown): Record<string, HeaderValue> => { · 127 unmodified lines
134138 // Store interface
135139 // ---------------------------------------------------------------------------
136140
141+// Every method routes through the typed adapter (`ctx.storage.adapter`)
142+// so the typed error channel is `StorageFailure`. Schema-decode failures
143+// inside `Effect.gen` land as defects, not typed errors, and are caught
144+// by the HTTP edge's observability middleware.
137145 export interface OpenapiStore {
138146 readonly upsertSource: (
139147 input: StoredSource,
140148 operations: readonly StoredOperation[],
141− ) => Effect.Effect<void, Error>;
149+ ) => Effect.Effect<void, StorageFailure>;
142150
143151 readonly updateSourceMeta: (
144152 namespace: string,
@@ -148,34 +156,34 @@ export interface OpenapiStore { · 3 unmodified lines
148156 readonly headers?: Record<string, HeaderValue>;
149157 readonly oauth2?: OAuth2Auth;
150158 },
151− ) => Effect.Effect<void, Error>;
159+ ) => Effect.Effect<void, StorageFailure>;
152160
153161 readonly getSource: (
154162 namespace: string,
155− ) => Effect.Effect<StoredSource | null, Error>;
163+ ) => Effect.Effect<StoredSource | null, StorageFailure>;
156164
157− readonly listSources: () => Effect.Effect<readonly StoredSource[], Error>;
165+ readonly listSources: () => Effect.Effect<readonly StoredSource[], StorageFailure>;
158166
159167 readonly getOperationByToolId: (
160168 toolId: string,
161− ) => Effect.Effect<StoredOperation | null, Error>;
169+ ) => Effect.Effect<StoredOperation | null, StorageFailure>;
162170
163171 readonly listOperationsBySource: (
164172 sourceId: string,
165− ) => Effect.Effect<readonly StoredOperation[], Error>;
173+ ) => Effect.Effect<readonly StoredOperation[], StorageFailure>;
166174
167− readonly removeSource: (namespace: string) => Effect.Effect<void, Error>;
175+ readonly removeSource: (namespace: string) => Effect.Effect<void, StorageFailure>;
168176
169177 readonly putOAuthSession: (
170178 sessionId: string,
171179 session: OpenApiOAuthSession,
172− ) => Effect.Effect<void, Error>;
180+ ) => Effect.Effect<void, StorageFailure>;
173181
174182 readonly getOAuthSession: (
175183 sessionId: string,
176− ) => Effect.Effect<OpenApiOAuthSession | null, Error>;
184+ ) => Effect.Effect<OpenApiOAuthSession | null, StorageFailure>;
177185
178− readonly deleteOAuthSession: (sessionId: string) => Effect.Effect<void, Error>;
186+ readonly deleteOAuthSession: (sessionId: string) => Effect.Effect<void, StorageFailure>;
179187 }
180188
181189 // ---------------------------------------------------------------------------
packages/plugins/workos-vault/src/sdk/secret-store.ts+9−6
@@ -3,8 +3,10 @@ import { GenericServerException, NotFoundException } from "@workos-inc/node/work · 2 unmodified lines
33
44 import {
55 defineSchema,
6+ StorageError,
67 type SecretProvider,
78 type StorageDeps,
9+ type StorageFailure,
810 } from "@executor/sdk";
911
1012 import {
@@ -50,10 +52,10 @@ interface MetadataRow { · 39 unmodified lines
5052 // ---------------------------------------------------------------------------
5153
5254 export interface WorkosVaultStore {
53− readonly get: (id: string) => Effect.Effect<MetadataRow | null, Error>;
54− readonly upsert: (row: MetadataRow) => Effect.Effect<void, Error>;
55− readonly remove: (id: string) => Effect.Effect<boolean, Error>;
56− readonly list: () => Effect.Effect<readonly MetadataRow[], Error>;
55+ readonly get: (id: string) => Effect.Effect<MetadataRow | null, StorageFailure>;
56+ readonly upsert: (row: MetadataRow) => Effect.Effect<void, StorageFailure>;
57+ readonly remove: (id: string) => Effect.Effect<boolean, StorageFailure>;
58+ readonly list: () => Effect.Effect<readonly MetadataRow[], StorageFailure>;
5759 }
5860
5961 export const makeWorkosVaultStore = (
@@ -215,9 +217,10 @@ const deleteSecretValue = ( · 155 unmodified lines
215217 return true;
216218 });
217219
218−const formatVaultError = (error: unknown): Error => {
220+const formatVaultError = (error: unknown): StorageError => {
219221 const cause = unwrapVaultError(error);
220− return cause instanceof Error ? cause : new Error(String(cause));
222+ const message = cause instanceof Error ? cause.message : String(cause);
223+ return new StorageError({ message, cause });
221224 };
222225
223226 // ---------------------------------------------------------------------------