History

Authorize from the mirror unconditionally; alert on a stale reconciler (#2031)

Rhys Sullivan committed 1 month ago

Commit a72e51d1 parent a6cdcf131 files · +652 −811Browse files

Unified · Split

31 files
  1. .changeset/mirror-readiness-removal.md +5 −0
  2. .claude/skills/prod-telemetry/SKILL.md +22 −11
  3. apps/cloud/src/account/account-api.ts +5 −8
  4. apps/cloud/src/account/org-api-key-revoke.node.test.ts +0 −8
  5. apps/cloud/src/account/workos-account-service.ts +1 −8
  6. apps/cloud/src/admin/admin-users-api.node.test.ts +6 −16
  7. apps/cloud/src/admin/admin-users-api.ts +5 −9
  8. apps/cloud/src/api/layers.ts +7 −19
  9. apps/cloud/src/api/protected-api-key-auth.node.test.ts +3 −11
  10. apps/cloud/src/api/protected-jwt-auth.node.test.ts +3 −11
  11. apps/cloud/src/api/protected.ts +1 −4
  12. apps/cloud/src/api/router.ts +1 −4
  13. apps/cloud/src/auth/doc-gate.ts +3 −3
  14. apps/cloud/src/auth/mirror-feeders.node.test.ts +50 −43
  15. apps/cloud/src/auth/mirror-readiness-store.ts +17 −20
  16. apps/cloud/src/auth/mirror-readiness.ts +0 −69
  17. apps/cloud/src/auth/org-api-key-auth.node.test.ts +5 −32
  18. apps/cloud/src/auth/org-selector-auth.node.test.ts +31 −166
  19. apps/cloud/src/auth/organization.ts +41 −99
  20. apps/cloud/src/auth/workos-auth-provider.ts +15 −21
  21. apps/cloud/src/auth/workos-events-runner.node.test.ts +81 −0
  22. apps/cloud/src/auth/workos-events-runner.ts +59 −3
  23. apps/cloud/src/auth/workos-events-sync.node.test.ts +35 −21
  24. apps/cloud/src/auth/workos-mirror.node.test.ts +37 −14
  25. apps/cloud/src/extensions/billing/route.node.test.ts +1 −10
  26. apps/cloud/src/extensions/routes.ts +4 −5
  27. apps/cloud/src/mcp/auth-provider.ts +16 −11
  28. apps/cloud/src/mcp/auth.ts +6 −5
  29. apps/cloud/src/org/auth-middleware.ts +8 −11
  30. apps/cloud/src/org/handlers.test.ts +48 −64
  31. e2e/cloud/mcp-workos-blip-session-survival.test.ts +136 −105
.changeset/mirror-readiness-removal.md+5−0
@@ -0,0 +1,5 @@
1+---
2+"@executor-js/cloud": patch
3+---
4+
5+`authorizeOrganization` now reads the local membership mirror unconditionally: the per-request readiness check (`MirrorReadiness`) and its live WorkOS `listUserMemberships` fallback are gone from the request path entirely. The backfill is complete and permanent, and an organization that predates the mirror is still covered by the existing on-demand scan (`ensureOrganizationBackfilled`). A stalled reconciler is now an operational alert instead of a per-request fallback: after each run, the cron checks the mirror's `drained_at` heartbeat and, if it has fallen behind the lag budget, logs a structured error and reports it to Sentry. The deploy gate (`scripts/ensure-workos-mirror-ready.ts`) is unchanged — it still refuses to ship while the mirror is unready — and `drained_at` keeps being written by every reconciler run.
.claude/skills/prod-telemetry/SKILL.md+22−11
@@ -57,22 +57,33 @@ join the same traces via traceparent). · 56 unmodified lines
5757 `execute`/`execute-action` calls `mcp.execute.code` (the script itself,
5858 capped at 10k chars — cloud-only content capture; local/self-host
5959 telemetry never records content).
60−- `auth.authorize_organization` — every membership authorization.
61− `mirror.ready` (bool: the local membership mirror answered; `false` =
62− the request fell back to a live WorkOS read) and `mirror.readiness`
63− (why: `ready`, `backfill pending: …`, `reconciler stale: …`). The
64− mirror's write spans are `workos_mirror.<op>`; the reconciler run is
60+- `auth.authorize_organization` — every membership authorization. Reads the
61+ local membership mirror unconditionally; there is no per-request readiness
62+ check and no WorkOS fallback, so this span carries no readiness attribute.
63+ The mirror's write spans are `workos_mirror.<op>`; the reconciler run is
6564 `workos_events.sync`. `workos_sync.drained_at` in the prod DB is the
66− reconciler heartbeat.
65+ reconciler heartbeat, and a stalled reconciler now raises its own error
66+ from the cron (see below) rather than showing up as a fallback here.
6767
68−**Recipe — membership-mirror fallback rate (should be ~0 after cutover):**
68+**Recipe — reconciler heartbeat (ticks should land roughly every minute; a
69+gap wider than the 10-minute lag budget means the cron alert should already
70+have fired — see `workos_events: reconciler stale` below):**
6971
7072 ```apl
7173 ['executor-cloud']
72−| where _time > ago(1h) and name == "auth.authorize_organization"
73−| extend ready = tobool(['attributes.custom']['mirror.ready'])
74−| extend why = tostring(['attributes.custom']['mirror.readiness'])
75−| summarize n = count() by ready, why
74+| where _time > ago(1h) and name == "workos_events.sync"
75+| summarize n = count() by bin(_time, 1m)
76+| sort by _time desc
77+```
78+
79+**Recipe — stale-reconciler alerts (should be empty; each row is one paging
80+event):**
81+
82+```apl
83+['executor-cloud']
84+| where _time > ago(1d) and ['status.message'] contains "workos_events: reconciler stale"
85+| project _time, trace_id, msg = tostring(['status.message'])
86+| sort by _time desc
7687 ```
7788
7889 **Recipe — error signatures by class (the daily-digest query):**
apps/cloud/src/account/account-api.ts+5−8
@@ -10,7 +10,6 @@ import { · 9 unmodified lines
1010
1111 import { ApiKeyService } from "../auth/api-keys";
1212 import { UserStoreService } from "../auth/context";
13−import { MirrorReadiness } from "../auth/mirror-readiness";
1413 import { WorkOsMirror } from "../auth/workos-mirror";
1514 import { sessionFromSealed, type Session } from "../auth/middleware";
1615 import { WorkOSClient } from "../auth/workos";
@@ -51,7 +50,9 @@ import { AccountCaller, workosAccountProvider } from "./workos-account-service"; · 34 unmodified lines
5150 // `UserStoreService` / `WorkOsMirror` / `MemberDirectory` are supplied by the
5251 // combined `rsLive` layer.
5352 // `ApiKeyService.WorkOS` is built here on top of the boot `WorkOSClient`.
54−const AccountProviderMiddleware = HttpRouter.middleware<{ provides: AccountProvider }>()(
53+const AccountProviderMiddleware = HttpRouter.middleware<{
54+ provides: AccountProvider;
55+}>()(
5556 Effect.gen(function* () {
5657 // Long-lived services only (built once at boot). `UserStoreService` and
5758 // `DbService` are NOT grabbed here — they come per request from the combined
@@ -100,15 +101,11 @@ const AccountProviderMiddleware = HttpRouter.middleware<{ provides: AccountProvi · 42 unmodified lines
100101 * (the seat-gate) stays a residual requirement, satisfied by the app `boot`.
101102 */
102103 export const workosAccountMiddleware = (
103− rsLive: Layer.Layer<
104− DbService | UserStoreService | WorkOsMirror | MemberDirectory | MirrorReadiness
105− >,
104+ rsLive: Layer.Layer<DbService | UserStoreService | WorkOsMirror | MemberDirectory>,
106105 ) => AccountProviderMiddleware.combine(requestScopedMiddleware(rsLive)).layer;
107106
108107 export const makeAccountApiLive = (
109− rsLive: Layer.Layer<
110− DbService | UserStoreService | WorkOsMirror | MemberDirectory | MirrorReadiness
111− >,
108+ rsLive: Layer.Layer<DbService | UserStoreService | WorkOsMirror | MemberDirectory>,
112109 ) => {
113110 // Cloud builds the WorkOS `AccountProvider` INSIDE the request body (so it
114111 // closes over the per-request postgres socket), so it can't be a self-
apps/cloud/src/account/org-api-key-revoke.node.test.ts+0−8
@@ -6,7 +6,6 @@ import { AccountError, AccountForbidden } from "@executor-js/api"; · 5 unmodified lines
66
77 import { ApiKeyService, OrgApiKeyNotFound } from "../auth/api-keys";
88 import { UserStoreService } from "../auth/context";
9−import { MirrorReadiness, MirrorReadinessState } from "../auth/mirror-readiness";
109 import { ORG_SELECTOR_HEADER } from "../auth/organization";
1110 import { WorkOSClient, type WorkOSClientService } from "../auth/workos";
1211 import { WorkOsMirror } from "../auth/workos-mirror";
@@ -132,12 +131,6 @@ const stubMirror = Layer.succeed(WorkOsMirror)({ · 119 unmodified lines
132131 // The mirror as the directory reads it: both are active members of ORG, and
133132 // only ADMIN carries the `admin` role. Revoke reads the caller's membership
134133 // (the org check and the admin gate) and nothing else.
135−// The mirror is READY in these tests (backfill complete, reconciler caught
136−// up), so membership is read from the stubbed directory, never from WorkOS.
137−const stubReadiness = Layer.succeed(MirrorReadiness)({
138− state: () => Effect.succeed(MirrorReadinessState.Ready()),
139−});
140−
141134 const stubDirectory = Layer.succeed(MemberDirectory)({
142135 membership: (accountId, organizationId) =>
143136 Effect.succeed(
@@ -201,7 +194,6 @@ const providerWith = (accountId: string) => { · 57 unmodified lines
201194 stubUsers,
202195 stubMirror,
203196 stubDirectory,
204− stubReadiness,
205197 stubApiKeys,
206198 stubAutumn,
207199 Layer.succeed(AccountCaller)({ session: session(accountId) }),
apps/cloud/src/account/workos-account-service.ts+1−8
@@ -11,7 +11,6 @@ import { · 10 unmodified lines
1111 import { ApiKeyService } from "../auth/api-keys";
1212 import { UserStoreService } from "../auth/context";
1313 import type { Session } from "../auth/middleware";
14−import { MirrorReadiness } from "../auth/mirror-readiness";
1514 import { WorkOSClient } from "../auth/workos";
1615 import { ensureOrganizationBackfilled, mirrorInvitedMember } from "../auth/mirror-feeders";
1716 import { WorkOsMirror, mirrorMembershipFromWorkOs } from "../auth/workos-mirror";
@@ -72,7 +71,6 @@ export const workosAccountProvider: Layer.Layer< · 54 unmodified lines
7271 | UserStoreService
7372 | WorkOsMirror
7473 | MemberDirectory
75− | MirrorReadiness
7674 | ApiKeyService
7775 | AutumnService
7876 | AccountCaller
@@ -103,12 +101,7 @@ export const workosAccountProvider: Layer.Layer< · 24 unmodified lines
103101 // erased to `R = never`, as the neutral AccountProvider shape requires.
104102 // Provided per method below.
105103 const ctx = yield* Effect.context<
106− | WorkOSClient
107− | UserStoreService
108− | AutumnService
109− | MemberDirectory
110− | MirrorReadiness
111− | WorkOsMirror
104+ WorkOSClient | UserStoreService | AutumnService | MemberDirectory | WorkOsMirror
112105 >();
113106
114107 // Unauthenticated (missing/invalid session) => AccountUnauthorized, exactly
apps/cloud/src/admin/admin-users-api.node.test.ts+6−16
@@ -6,7 +6,6 @@ import { MemberDirectory, type DirectoryMember } from "@executor-js/api/server"; · 5 unmodified lines
66
77 import { ApiKeyService } from "../auth/api-keys";
88 import { UserStoreService } from "../auth/context";
9−import { MirrorReadiness, MirrorReadinessState } from "../auth/mirror-readiness";
109 import { ORG_SELECTOR_HEADER } from "../auth/organization";
1110 import { WorkOSClient, type WorkOSClientService } from "../auth/workos";
1211 import { WorkOsMirror, type WorkOsMirrorShape } from "../auth/workos-mirror";
@@ -49,12 +48,6 @@ const memberships = new Map<string, DirectoryMember>([ · 36 unmodified lines
4948 ["user_invited_admin", mirrored("user_invited_admin", { role: "admin", status: "pending" })],
5049 ]);
5150
52−// The mirror is READY in these tests (backfill complete, reconciler caught
53−// up), so membership is read from the stubbed directory, never from WorkOS.
54−const stubReadiness = Layer.succeed(MirrorReadiness)({
55− state: () => Effect.succeed(MirrorReadinessState.Ready()),
56−});
57−
5851 const stubDirectory = Layer.succeed(MemberDirectory)({
5952 membership: (accountId, organizationId) =>
6053 Effect.succeed(organizationId === ORG ? (memberships.get(accountId) ?? null) : null),
@@ -149,7 +142,11 @@ const stubWorkOS = (userId: string) => · 88 unmodified lines
149142 get: (_target, prop) => {
150143 if (prop === "authenticateRequest") {
151144 return () =>
152− Effect.succeed({ userId, email: `${userId}@placeholder.test`, organizationId: null });
145+ Effect.succeed({
146+ userId,
147+ email: `${userId}@placeholder.test`,
148+ organizationId: null,
149+ });
153150 }
154151 return () => Effect.die(`unexpected WorkOSClient.${String(prop)} call`);
155152 },
@@ -163,14 +160,7 @@ const authorizeAs = (userId: string) => · 7 unmodified lines
163160 }),
164161 ).pipe(
165162 Effect.provide(
166− Layer.mergeAll(
167− stubDirectory,
168− stubApiKeys,
169− stubUsers,
170− stubWorkOS(userId),
171− stubMirror,
172− stubReadiness,
173− ),
163+ Layer.mergeAll(stubDirectory, stubApiKeys, stubUsers, stubWorkOS(userId), stubMirror),
174164 ),
175165 );
176166
apps/cloud/src/admin/admin-users-api.ts+5−9
@@ -56,7 +56,6 @@ import type { Executor } from "@executor-js/sdk"; · 55 unmodified lines
5656
5757 import { ApiKeyService } from "../auth/api-keys";
5858 import { UserStoreService } from "../auth/context";
59−import { MirrorReadiness } from "../auth/mirror-readiness";
6059 import { WorkOsMirror } from "../auth/workos-mirror";
6160 import { isPlatformAuth, resolveBearerAuth } from "../auth/workos-auth-provider";
6261 import { orgSelectorFromRequest, authorizeOrganizationSelector } from "../auth/organization";
@@ -77,7 +76,7 @@ export const authorizeTenant = ( · 14 unmodified lines
7776 ): Effect.Effect<
7877 string,
7978 AdminUsersUnauthorized | AdminUsersForbidden,
80− WorkOSClient | ApiKeyService | UserStoreService | MemberDirectory | MirrorReadiness | WorkOsMirror
79+ WorkOSClient | ApiKeyService | UserStoreService | MemberDirectory | WorkOsMirror
8180 > =>
8281 Effect.gen(function* () {
8382 // (1) The bearer path. `resolveBearerAuth` (not `resolveApiKeyPrincipal`,
@@ -138,7 +137,6 @@ const withPlatformView = <A, E extends AdminUsersError | AdminUserNotFound = Adm · 54 unmodified lines
138137 | ApiKeyService
139138 | UserStoreService
140139 | MemberDirectory
141− | MirrorReadiness
142140 | WorkOsMirror
143141 | DbProvider
144142 | PluginsProvider
@@ -178,7 +176,6 @@ export const workosAdminUsersProvider: Layer.Layer< · 33 unmodified lines
178176 | ApiKeyService
179177 | UserStoreService
180178 | MemberDirectory
181− | MirrorReadiness
182179 | WorkOsMirror
183180 | DbProvider
184181 | PluginsProvider
@@ -190,7 +187,6 @@ export const workosAdminUsersProvider: Layer.Layer< · 5 unmodified lines
190187 | ApiKeyService
191188 | UserStoreService
192189 | MemberDirectory
193− | MirrorReadiness
194190 | WorkOsMirror
195191 | DbProvider
196192 | PluginsProvider
@@ -241,7 +237,9 @@ export const workosAdminUsersProvider: Layer.Layer< · 44 unmodified lines
241237 // per-request `DbService`/`UserStoreService`/`MemberDirectory` (and the
242238 // execution seams built over them) are supplied by the combined
243239 // `requestScopedMiddleware`.
244−const AdminUsersProviderMiddleware = HttpRouter.middleware<{ provides: AdminUsersProvider }>()(
240+const AdminUsersProviderMiddleware = HttpRouter.middleware<{
241+ provides: AdminUsersProvider;
242+}>()(
245243 Effect.gen(function* () {
246244 const longLived = yield* Effect.context<WorkOSClient | ApiKeyService>();
247245 return (httpEffect) =>
@@ -265,9 +263,7 @@ const AdminUsersProviderMiddleware = HttpRouter.middleware<{ provides: AdminUser · 17 unmodified lines
265263 * `/api` prefix as the rest of the cloud router.
266264 */
267265 export const makeCloudAdminUsersRoutes = (
268− rsLive: Layer.Layer<
269− DbService | UserStoreService | MemberDirectory | MirrorReadiness | WorkOsMirror
270− >,
266+ rsLive: Layer.Layer<DbService | UserStoreService | MemberDirectory | WorkOsMirror>,
271267 options: Parameters<typeof makeAdminUsersApiLayer>[1] = {},
272268 ) =>
273269 makeAdminUsersApiLayer(
apps/cloud/src/api/layers.ts+7−19
@@ -11,7 +11,6 @@ import { · 10 unmodified lines
1111 import { SessionAuthLive } from "../auth/middleware-live";
1212 import { UserStoreService } from "../auth/context";
1313 import { cloudMemberDirectoryLayer } from "../auth/member-directory";
14−import { MirrorReadiness } from "../auth/mirror-readiness";
1514 import { WorkOsMirror } from "../auth/workos-mirror";
1615 import {
1716 CloudAuthPublicHandlers,
@@ -36,23 +35,14 @@ const WorkOsMirrorLive = WorkOsMirror.Live.pipe(Layer.provide(DbLive)); · 18 unmodified lines
3635 // The shared `MemberDirectory` read seam over the membership mirror — the
3736 // same per-request socket the mirror writes through.
3837 const MemberDirectoryLive = cloudMemberDirectoryLayer.pipe(Layer.provide(DbLive));
39−// Whether the mirror may authorize this request at all (backfill complete,
40−// reconciler caught up) — read on the same socket before the membership row.
41−const MirrorReadinessLive = MirrorReadiness.Live.pipe(Layer.provide(DbLive));
4238
4339 // Per-request layer. Anything that opens an I/O object (postgres.js socket,
4440 // fetch stream readers, anything backed by a `Writable`) MUST live here —
4541 // `provideRequestScoped` rebuilds it per request so Cloudflare Workers'
4642 // I/O isolation is satisfied. See `api.request-scope.test.ts`.
4743 export const RequestScopedServicesLive: Layer.Layer<
48− DbService | UserStoreService | WorkOsMirror | MemberDirectory | MirrorReadiness
49−> = Layer.mergeAll(
50− DbLive,
51− UserStoreLive,
52− WorkOsMirrorLive,
53− MemberDirectoryLive,
54− MirrorReadinessLive,
55−);
44+ DbService | UserStoreService | WorkOsMirror | MemberDirectory
45+> = Layer.mergeAll(DbLive, UserStoreLive, WorkOsMirrorLive, MemberDirectoryLive);
5646
5747 // Boot-scoped layer. Built once at worker boot, reused across requests.
5848 // Safe for config, in-memory caches, the global tracer provider, and
@@ -77,9 +67,7 @@ export const BootSharedServices = Layer.mergeAll( · 18 unmodified lines
7767 // handler reads it for the free-organizations-per-user limit gate — one of the
7868 // few app-only billing touchpoints. (It is NOT on the neutral boot core.)
7969 export const makeNonProtectedApiLive = (
80− rsLive: Layer.Layer<
81− DbService | UserStoreService | WorkOsMirror | MemberDirectory | MirrorReadiness
82− >,
70+ rsLive: Layer.Layer<DbService | UserStoreService | WorkOsMirror | MemberDirectory>,
8371 ) =>
8472 HttpApiBuilder.layer(NonProtectedApi).pipe(
8573 Layer.provide(Layer.mergeAll(CloudAuthPublicHandlers, CloudSessionAuthHandlers)),
@@ -95,9 +83,7 @@ export const makeNonProtectedApiLive = ( · 9 unmodified lines
9583 // gates on billing, so `AutumnService.Default` is provided here, not on the
9684 // neutral boot core.
9785 export const makeOrgApiLive = (
98− rsLive: Layer.Layer<
99− DbService | UserStoreService | MemberDirectory | MirrorReadiness | WorkOsMirror
100− >,
86+ rsLive: Layer.Layer<DbService | UserStoreService | MemberDirectory | WorkOsMirror>,
10187 ) =>
10288 HttpApiBuilder.layer(OrgHttpApi).pipe(
10389 Layer.provide(OrgHandlers),
@@ -143,7 +129,9 @@ export const OrgApiLive = makeOrgApiLive(RequestScopedServicesLive); · 39 unmodified lines
143129 // folded into `.layer` here; the rest of the router (`makeApiLive` in
144130 // `./router.ts`, `./protected.ts`, the test harness) re-provides the same
145131 // shared `RouterConfigLive` directly.
146−const protectedApi = makeProtectedApiLayer(cloudPlugins, { errorCapture: ErrorCaptureLive });
132+const protectedApi = makeProtectedApiLayer(cloudPlugins, {
133+ errorCapture: ErrorCaptureLive,
134+});
147135
148136 export const ProtectedCloudApi = protectedApi.api;
149137 export const ProtectedCloudApiHandlers = protectedApi.handlers;
apps/cloud/src/api/protected-api-key-auth.node.test.ts+3−11
@@ -5,7 +5,6 @@ import { MemberDirectory } from "@executor-js/api/server"; · 4 unmodified lines
55
66 import { ApiKeyService } from "../auth/api-keys";
77 import { UserStoreService } from "../auth/context";
8−import { MirrorReadiness, MirrorReadinessState } from "../auth/mirror-readiness";
98 import { WorkOSClient, type WorkOSClientService } from "../auth/workos";
109 import { WorkOsMirror, type WorkOsMirrorShape } from "../auth/workos-mirror";
1110 import { resolveProtectedPrincipal } from "./protected";
@@ -55,13 +54,8 @@ const stubWorkOS = Layer.succeed( · 43 unmodified lines
5554 );
5655
5756 // The mirror as the directory reads it: user_123 holds an active membership in
58−// org_123 and nothing else. Membership is never read from WorkOS.
59−// The mirror is READY in these tests (backfill complete, reconciler caught
60−// up), so membership is read from the stubbed directory, never from WorkOS.
61−const stubReadiness = Layer.succeed(MirrorReadiness)({
62− state: () => Effect.succeed(MirrorReadinessState.Ready()),
63−});
64−
57+// org_123 and nothing else. Membership is always read from the mirror, never
58+// from WorkOS.
6559 const stubDirectory = Layer.succeed(MemberDirectory)({
6660 membership: (accountId, organizationId) =>
6761 Effect.succeed(
@@ -136,9 +130,7 @@ const stubMirror = Layer.succeed( · 68 unmodified lines
136130
137131 const run = (request: Request) =>
138132 resolveProtectedPrincipal(request).pipe(
139− Effect.provide(
140− Layer.mergeAll(stubApiKeys, stubWorkOS, stubUsers, stubDirectory, stubMirror, stubReadiness),
141− ),
133+ Effect.provide(Layer.mergeAll(stubApiKeys, stubWorkOS, stubUsers, stubDirectory, stubMirror)),
142134 );
143135
144136 describe("protected API key auth", () => {
apps/cloud/src/api/protected-jwt-auth.node.test.ts+3−11
@@ -6,7 +6,6 @@ import { MemberDirectory } from "@executor-js/api/server"; · 5 unmodified lines
66
77 import { ApiKeyService } from "../auth/api-keys";
88 import { UserStoreService } from "../auth/context";
9−import { MirrorReadiness, MirrorReadinessState } from "../auth/mirror-readiness";
109 import type { JwtBearerConfig } from "../auth/workos-auth-provider";
1110 import { WorkOSClient, type WorkOSClientService } from "../auth/workos";
1211 import { WorkOsMirror, type WorkOsMirrorShape } from "../auth/workos-mirror";
@@ -72,13 +71,8 @@ const stubWorkOS = Layer.succeed( · 59 unmodified lines
7271 );
7372
7473 // The mirror as the directory reads it: user_123 holds an active membership in
75−// org_123 and nothing else. Membership is never read from WorkOS.
76−// The mirror is READY in these tests (backfill complete, reconciler caught
77−// up), so membership is read from the stubbed directory, never from WorkOS.
78−const stubReadiness = Layer.succeed(MirrorReadiness)({
79− state: () => Effect.succeed(MirrorReadinessState.Ready()),
80−});
81−
74+// org_123 and nothing else. Membership is always read from the mirror, never
75+// from WorkOS.
8276 const stubDirectory = Layer.succeed(MemberDirectory)({
8377 membership: (accountId, organizationId) =>
8478 Effect.succeed(
@@ -153,9 +147,7 @@ const stubMirror = Layer.succeed( · 68 unmodified lines
153147
154148 const run = (request: Request, jwt: JwtBearerConfig) =>
155149 resolveProtectedPrincipal(request, jwt).pipe(
156− Effect.provide(
157− Layer.mergeAll(stubApiKeys, stubWorkOS, stubUsers, stubDirectory, stubMirror, stubReadiness),
158− ),
150+ Effect.provide(Layer.mergeAll(stubApiKeys, stubWorkOS, stubUsers, stubDirectory, stubMirror)),
159151 );
160152
161153 const request = (token: string) =>
apps/cloud/src/api/protected.ts+1−4
@@ -16,7 +16,6 @@ import { · 15 unmodified lines
1616 import { cloudPlugins, type CloudPlugins } from "../plugins";
1717 import { ApiKeyService } from "../auth/api-keys";
1818 import { UserStoreService } from "../auth/context";
19−import { MirrorReadiness } from "../auth/mirror-readiness";
2019 import { WorkOsMirror } from "../auth/workos-mirror";
2120 import { cloudIdentityFailureStrategy, workosIdentityLayer } from "../auth/workos-auth-provider";
2221 import { AutumnService } from "../extensions/billing/service";
@@ -97,9 +96,7 @@ const ExecutionStackMiddleware = makeExecutionStackMiddleware< · 74 unmodified lines
9796 // account seat-gate, and the createOrganization free-limit gate each provide it
9897 // where they run.)
9998 export const makeProtectedApiLive = (
100− rsLive: Layer.Layer<
101− DbService | UserStoreService | MemberDirectory | MirrorReadiness | WorkOsMirror
102− >,
99+ rsLive: Layer.Layer<DbService | UserStoreService | MemberDirectory | WorkOsMirror>,
103100 ) => {
104101 // The neutral `IdentityProvider`, built per request: it reads `UserStoreService`
105102 // + `MemberDirectory` from `rsLive` and the WorkOS control plane (`WorkOSClient` + `ApiKeyService`,
apps/cloud/src/api/router.ts+1−4
@@ -8,7 +8,6 @@ import { · 7 unmodified lines
88 } from "@executor-js/api/server";
99
1010 import { UserStoreService } from "../auth/context";
11−import { MirrorReadiness } from "../auth/mirror-readiness";
1211 import { WorkOsMirror } from "../auth/workos-mirror";
1312 import { DbService } from "../db/db";
1413 import { makeAccountApiLive } from "../account/account-api";
@@ -36,9 +35,7 @@ import { makeProtectedApiLive } from "./protected"; · 21 unmodified lines
3635 // assert per-request semantics — see
3736 // `apps/cloud/src/api.request-scope.node.test.ts`.
3837 export const makeApiLive = (
39− requestScopedLive: Layer.Layer<
40− DbService | UserStoreService | WorkOsMirror | MemberDirectory | MirrorReadiness
41− >,
38+ requestScopedLive: Layer.Layer<DbService | UserStoreService | WorkOsMirror | MemberDirectory>,
4239 ) => {
4340 const BillingRoutesLive = AutumnRoutesLive.pipe(
4441 Layer.provide(requestScopedMiddleware(requestScopedLive).layer),
apps/cloud/src/auth/doc-gate.ts+3−3
@@ -42,7 +42,6 @@ import { makeUserStoreLayer, UserStoreService } from "./context"; · 41 unmodified lines
4242 import { parseCookie } from "./cookies";
4343 import { LAST_ORG_COOKIE } from "./last-org-cookie";
4444 import { makeMemberDirectoryLayer } from "./member-directory";
45−import { makeMirrorReadinessLayer } from "./mirror-readiness";
4645 import { makeWorkOsMirrorLayer } from "./workos-mirror";
4746 import { sealedSessionDisplayName } from "./middleware";
4847 import { authorizeOrganizationSelector } from "./organization";
@@ -184,7 +183,6 @@ const authorizeLastOrgSlug = async ( · 135 unmodified lines
184183 Layer.mergeAll(
185184 makeUserStoreLayer(),
186185 makeMemberDirectoryLayer(),
187− makeMirrorReadinessLayer(),
188186 makeWorkOsMirrorLayer(),
189187 ).pipe(Layer.provide(dbLive)),
190188 ),
@@ -258,7 +256,9 @@ export const authGateMiddleware = createMiddleware({ type: "request" }).server( · 67 unmodified lines
258256 // the client AuthGate makes mid-session, made here before the document
259257 // exists so the app shell is never painted for an org-less session.
260258 if (!session.organizationId && !ONBOARDING_PATHS.has(pathname)) {
261− return redirect("/create-org", { refreshedSession: session.refreshedSession });
259+ return redirect("/create-org", {
260+ refreshedSession: session.refreshedSession,
261+ });
262262 }
263263
264264 // A BARE console path (no org slug in the URL) canonicalizes onto the org
apps/cloud/src/auth/mirror-feeders.node.test.ts+50−43
@@ -87,7 +87,6 @@ import { encodeLoginState } from "./login-state"; · 86 unmodified lines
8787 import { cloudMemberDirectoryLayer } from "./member-directory";
8888 import { SessionAuthLive } from "./middleware-live";
8989 import { mirrorSignIn } from "./mirror-feeders";
90−import { MirrorReadiness, MirrorReadinessState } from "./mirror-readiness";
9190 import {
9291 ORG_SELECTOR_HEADER,
9392 authorizeOrganization,
@@ -172,14 +171,6 @@ const seedOrganization = (id: string) => · 78 unmodified lines
172171 ),
173172 );
174173
175−// The mirror is READY throughout (backfill complete, reconciler caught up):
176−// every membership read below is against the mirror, never WorkOS. The
177−// readiness rule itself is pinned in workos-mirror.node.test.ts and the
178−// fallback in org-selector-auth.node.test.ts.
179−const readyMirror = Layer.succeed(MirrorReadiness)({
180− state: () => Effect.succeed(MirrorReadinessState.Ready()),
181−});
182−
183174 const stubAutumn = Layer.succeed(AutumnService)({
184175 use: () => Effect.die("feeders do not read billing"),
185176 ensureCustomer: () => Effect.void,
@@ -500,7 +491,12 @@ describe("a delayed sign-in feeder", () => { · 314 unmodified lines
500491 const rejoinedAt = "2026-01-03T00:00:00.000Z";
501492 yield* mirrorSignIn(
502493 workosUser(userId),
503− [workosMembership(userId, org, { id: `om_${userId}_${org}_2`, updatedAt: rejoinedAt })],
494+ [
495+ workosMembership(userId, org, {
496+ id: `om_${userId}_${org}_2`,
497+ updatedAt: rejoinedAt,
498+ }),
499+ ],
504500 new Date(rejoinedAt),
505501 );
506502 return { membership, rejoined: yield* readMembership(userId, org) };
@@ -581,20 +577,14 @@ describe("session handlers read membership from the mirror", () => { · 74 unmodified lines
581577 DbService | UserStoreService | WorkOsMirror | MemberDirectory
582578 >;
583579 readonly autumn?: Layer.Layer<AutumnService>;
584− /** The mirror's readiness for this request; ready unless a test says otherwise. */
585− readonly readiness?: Layer.Layer<MirrorReadiness>;
586580 } = {},
587581 ) =>
588582 HttpRouter.toWebHandler(
589583 HttpApiBuilder.layer(NonProtectedApi).pipe(
590584 Layer.provide(Layer.mergeAll(CloudAuthPublicHandlers, CloudSessionAuthHandlers)),
591585 Layer.provide(
592− requestScopedMiddleware(
593− Layer.mergeAll(
594− options.services ?? RequestScopedServicesLive,
595− options.readiness ?? readyMirror,
596− ),
597− ).layer,
586+ requestScopedMiddleware(Layer.mergeAll(options.services ?? RequestScopedServicesLive))
587+ .layer,
598588 ),
599589 Layer.provideMerge(SessionAuthLive),
600590 Layer.provideMerge(options.autumn ?? stubAutumn),
@@ -627,9 +617,10 @@ describe("session handlers read membership from the mirror", () => { · 26 unmodified lines
627617 );
628618
629619 /**
630− * `authorizeOrganization` over the live stores and a READY mirror, as every
631− * protected request runs it; `workos` serves whatever the check may read
632− * from WorkOS (nothing, by default: any read dies).
620+ * `authorizeOrganization` over the live stores, as every protected request
621+ * runs it: membership is read from the mirror unconditionally; `workos`
622+ * serves whatever the check may read from WorkOS (nothing, by default: any
623+ * read dies).
633624 */
634625 const authorize = (
635626 userId: string,
@@ -639,12 +630,9 @@ describe("session handlers read membership from the mirror", () => { · 3 unmodified lines
639630 Effect.runPromise(
640631 authorizeOrganization(userId, org).pipe(
641632 Effect.provide(
642− Layer.mergeAll(
643− UserStoreService.Live,
644− WorkOsMirror.Live,
645− cloudMemberDirectoryLayer,
646− readyMirror,
647− ).pipe(Layer.provideMerge(DbService.Live)),
633+ Layer.mergeAll(UserStoreService.Live, WorkOsMirror.Live, cloudMemberDirectoryLayer).pipe(
634+ Layer.provideMerge(DbService.Live),
635+ ),
648636 ),
649637 Effect.provide(workos),
650638 Effect.scoped,
@@ -665,7 +653,12 @@ describe("session handlers read membership from the mirror", () => { · 14 unmodified lines
665653 purges.push(op);
666654 }).pipe(
667655 Effect.flatMap(() =>
668− Effect.fail(new UserStoreError({ operation: op, reason: "connection_closed" })),
656+ Effect.fail(
657+ new UserStoreError({
658+ operation: op,
659+ reason: "connection_closed",
660+ }),
661+ ),
669662 ),
670663 )
671664 : live.use(op, fn),
@@ -914,7 +907,7 @@ describe("session handlers read membership from the mirror", () => { · 242 unmodified lines
914907 expect(await authorized(admin, org)).toBe(false);
915908 });
916909
917− it("finishes on a retry while the mirror is not ready, after WorkOS already deleted the org", async () => {
910+ it("finishes on a retry after WorkOS already deleted the org", async () => {
918911 const admin = freshId("user");
919912 const member = freshId("user");
920913 const org = freshId("org");
@@ -931,19 +924,17 @@ describe("session handlers read membership from the mirror", () => { · 10 unmodified lines
931924 expect(first.status).toBe(500);
932925 expect(purges).toEqual(["deleteOrganizationCascade"]);
933926
934− // The reconciler stalls before the retry. WorkOS no longer lists the org
935− // or the admin's membership in it — and the fallback must not ask it:
936− // the stub dies on `listUserMemberships`. The admin's own mirror row,
937− // which the failed purge left behind, is what admits the retry.
927+ // WorkOS no longer has the org to delete a second time. The admin's own
928+ // mirror row, which the failed purge left behind, is what admits the
929+ // retry — membership is read from the mirror unconditionally.
938930 const retry = sessionHandler(admin, {
939931 autumn: deletingAutumn,
940− readiness: Layer.succeed(MirrorReadiness)({
941− state: () => Effect.succeed(MirrorReadinessState.ReconcilerStale({ drainedAt: null })),
942− }),
943− workos: { deleteOrganization: () => Effect.fail(new WorkOSError({ status: 404 })) },
932+ workos: {
933+ deleteOrganization: () => Effect.fail(new WorkOSError({ status: 404 })),
934+ },
944935 });
945936 const second = await retry(deleteOrganizationRequest(org));
946− expect(second.status, "the retry is admitted from the mirror, not WorkOS").toBe(200);
937+ expect(second.status, "the retry is admitted from the mirror").toBe(200);
947938 expect(await second.json()).toEqual({ success: true });
948939 expect(await readMembers(org), "and the purge ran").toEqual([]);
949940 expect((await readOrganization(org))?.deletedAt).not.toBeNull();
@@ -961,7 +952,9 @@ describe("session handlers read membership from the mirror", () => { · 11 unmodified lines
961952 calls.push(`getUserOrgMembership:${userId}`);
962953 return Effect.succeed(
963954 userId === memberId
964− ? (workosMembership(userId, organizationId, { role: { slug: "admin" } }) as never)
955+ ? (workosMembership(userId, organizationId, {
956+ role: { slug: "admin" },
957+ }) as never)
965958 : null,
966959 );
967960 },
@@ -1089,7 +1082,9 @@ describe("session handlers read membership from the mirror", () => { · 121 unmodified lines
10891082 );
10901083
10911084 expect(response.status).toBe(200);
1092− const body = (await response.json()) as { organizations: { id: string; slug: string }[] };
1085+ const body = (await response.json()) as {
1086+ organizations: { id: string; slug: string }[];
1087+ };
10931088 expect(body.organizations.map((o) => [o.id, o.slug])).toEqual([[live, liveSlug]]);
10941089 });
10951090 });
@@ -1156,7 +1151,6 @@ describe("account service writes through to the mirror", () => { · 60 unmodified lines
11561151 UserStoreService.Live,
11571152 WorkOsMirror.Live,
11581153 cloudMemberDirectoryLayer,
1159− readyMirror,
11601154 );
11611155 return workosAccountProvider.pipe(
11621156 Layer.provide(
@@ -1778,7 +1772,17 @@ describe("backfill", () => { · 615 unmodified lines
17781772 );
17791773
17801774 const counts = await runBackfill(
1781− new Map([[org, [workosMembership(paused, org, { status: "inactive", updatedAt: T2 })]]]),
1775+ new Map([
1776+ [
1777+ org,
1778+ [
1779+ workosMembership(paused, org, {
1780+ status: "inactive",
1781+ updatedAt: T2,
1782+ }),
1783+ ],
1784+ ],
1785+ ]),
17821786 false,
17831787 );
17841788 expect(counts, "the deactivated membership is written, not tombstoned").toMatchObject({
@@ -1904,7 +1908,10 @@ describe("backfill", () => { · 119 unmodified lines
19041908 [orgB, [workosMembership(staying, orgB)]],
19051909 ]);
19061910 const retried = await runBackfill(attemptB, false);
1907− expect(retried).toMatchObject({ organizations: 2, membershipsTombstoned: 1 });
1911+ expect(retried).toMatchObject({
1912+ organizations: 2,
1913+ membershipsTombstoned: 1,
1914+ });
19081915 expect(
19091916 await syncState(),
19101917 "the retry keeps the first attempt's boundary instead of taking a later one",
apps/cloud/src/auth/mirror-readiness-store.ts+17−20
@@ -1,33 +1,30 @@
11 // ---------------------------------------------------------------------------
2−// Mirror READINESS: whether the local membership mirror may be trusted as
3−// the membership authority for a request, or WorkOS must still be asked.
2+// Mirror READINESS: whether the local membership mirror has ever been fit to
3+// authorize from, per the ORIGINAL cutover rule. The request path
4+// (`organization.ts`) no longer consults this — it authorizes from the
5+// mirror unconditionally, because the one-off backfill is complete and
6+// permanent and a pre-mirror organization is covered by the on-demand scan
7+// (`ensureOrganizationBackfilled`). What remains is the deploy gate
8+// (`scripts/ensure-workos-mirror-ready.ts`), which still refuses to ship a
9+// build that trusts the mirror while it is unready, and the reconciler's own
10+// staleness alert (`workos-events-runner.ts`), which reads `drainedAt` after
11+// each run and raises a Sentry error when the drain has fallen behind the lag
12+// budget below — a stalled reconciler is now an operational page, not a
13+// per-request fallback.
414 //
5−// The mirror is fed by login, write-through, and the Events API reconciler
6−// (`workos-mirror.ts`), and is complete only once the one-off backfill has
7−// written every organization and the reconciler has caught up to the
8−// present. Before that, two things go wrong if it is trusted anyway:
9−// - a member who has not signed in since the mirror shipped has no row
10−// yet, and every protected request of theirs is refused — the backfill
11−// is what writes them;
12−// - a member revoked in the WorkOS dashboard while the reconciler was not
13−// running still holds an active row, and keeps their access until the
14−// stream is replayed — the reconciler is what tombstones them.
15−// So readiness is BOTH: the backfill's completion mark
15+// Readiness is BOTH: the backfill's completion mark
1616 // (`workos_sync.backfill_completed_at`, written once by a run that covered
1717 // every live organization) AND a recent drain of the events stream
1818 // (`workos_sync.drained_at`, moved forward by every reconciler run that read
1919 // the stream to its end). The lag budget bounds how far behind the reconciler
2020 // may be: it runs every minute, so a mark older than the budget means it has
2121 // stalled (WorkOS unreachable, the cron not deployed, a backlog draining over
22−// many runs) and the mirror may be missing revocations. While either half is
23−// missing the authorization path reads membership from WorkOS instead
24−// (`organization.ts`), exactly as it did before the cutover; nothing is
25−// denied or granted on the mirror's word.
22+// many runs) and the mirror may be missing revocations.
2623 //
2724 // The rule and the row read live here, free of `cloudflare:workers`, so the
28−// deploy gate (`scripts/ensure-workos-mirror-ready.ts`) applies the SAME rule
29−// over a plain postgres.js connection under bun before the build that trusts
30−// the mirror goes live. The request-scoped service is `mirror-readiness.ts`.
25+// deploy gate applies the SAME rule over a plain postgres.js connection under
26+// bun before a build goes live, and the reconciler's alert applies the SAME
27+// `drainedAt` age check the gate does.
3128 // ---------------------------------------------------------------------------
3229
3330 import { eq } from "drizzle-orm";
apps/cloud/src/auth/mirror-readiness.ts+0−69
@@ -1,69 +0,0 @@
1−// ---------------------------------------------------------------------------
2−// MirrorReadiness — the request-scoped service that answers whether the
3−// membership mirror may authorize this request (see
4−// `mirror-readiness-store.ts` for the rule and why it exists).
5−//
6−// Per-request layer shape, like `UserStoreService` and `WorkOsMirror`: it
7−// reads the request's postgres socket, so it is rebuilt per request
8−// (`RequestScopedServicesLive`) and never shared across Workers requests. One
9−// indexed point read per authorization, on the same socket the membership
10−// read uses next.
11−// ---------------------------------------------------------------------------
12−
13−import { Clock, Context, Effect, Layer } from "effect";
14−
15−import { DbService, type DrizzleDb } from "../db/db";
16−import {
17− WorkOsMirrorError,
18− tryPromiseService,
19− userStoreReasonFromCause,
20− withServiceLogging,
21−} from "./errors";
22−import { readMirrorReadiness, type MirrorReadinessState } from "./mirror-readiness-store";
23−
24−export {
25− MIRROR_RECONCILER_LAG_BUDGET,
26− MirrorReadinessState,
27− describeMirrorReadiness,
28− mirrorReadinessFrom,
29− type MirrorReadinessRow,
30−} from "./mirror-readiness-store";
31−
32−export interface MirrorReadinessShape {
33− /**
34− * The mirror's readiness as of now. Fails with `WorkOsMirrorError` when the
35− * row cannot be read — the caller must not treat that as either ready or
36− * not; it is the same infra failure as any other mirror read.
37− */
38− readonly state: () => Effect.Effect<MirrorReadinessState, WorkOsMirrorError>;
39−}
40−
41−const makeService = (db: DrizzleDb): MirrorReadinessShape => ({
42− state: () =>
43− Effect.flatMap(Clock.currentTimeMillis, (millis) =>
44− withServiceLogging(
45− "workos_mirror.readiness",
46− (failure) =>
47− new WorkOsMirrorError({
48− operation: "readiness",
49− reason: userStoreReasonFromCause(failure),
50− }),
51− tryPromiseService(() => readMirrorReadiness(db, new Date(millis))),
52− ),
53− ),
54−});
55−
56−export class MirrorReadiness extends Context.Service<MirrorReadiness, MirrorReadinessShape>()(
57− "@executor-js/cloud/MirrorReadiness",
58−) {
59− static Live = Layer.effect(this)(Effect.map(DbService.asEffect(), ({ db }) => makeService(db)));
60−}
61−
62−/**
63− * A FRESH `MirrorReadiness` layer (new layer value per call), for a service
64− * built once but invoked across many Workers requests — the MCP
65− * org-authorization seam and the document gate — for the same reason
66− * `makeUserStoreLayer` exists. See [[makeDbLayer]].
67− */
68−export const makeMirrorReadinessLayer = (): Layer.Layer<MirrorReadiness, never, DbService> =>
69− Layer.effect(MirrorReadiness)(Effect.map(DbService.asEffect(), ({ db }) => makeService(db)));
apps/cloud/src/auth/org-api-key-auth.node.test.ts+5−32
@@ -5,7 +5,6 @@ import { MemberDirectory, NoOrganization } from "@executor-js/api/server"; · 4 unmodified lines
55
66 import { ApiKeyService } from "./api-keys";
77 import { UserStoreService } from "./context";
8−import { MirrorReadiness, MirrorReadinessState } from "./mirror-readiness";
98 import { WorkOSClient, type WorkOSClientService } from "./workos";
109 import { WorkOsMirror, type WorkOsMirrorShape } from "./workos-mirror";
1110 import { isPlatformAuth, resolveApiKeyPrincipal, resolveBearerAuth } from "./workos-auth-provider";
@@ -70,13 +69,8 @@ const stubWorkOS = Layer.succeed( · 58 unmodified lines
7069 );
7170
7271 // The mirror as the directory reads it: user_123 holds an active membership in
73−// org_123 and nothing else. Membership is never read from WorkOS.
74−// The mirror is READY in these tests (backfill complete, reconciler caught
75−// up), so membership is read from the stubbed directory, never from WorkOS.
76−const stubReadiness = Layer.succeed(MirrorReadiness)({
77− state: () => Effect.succeed(MirrorReadinessState.Ready()),
78−});
79−
72+// org_123 and nothing else. Membership is always read from the mirror, never
73+// from WorkOS.
8074 const stubDirectory = Layer.succeed(MemberDirectory)({
8175 membership: (accountId, organizationId) =>
8276 Effect.succeed(
@@ -149,14 +143,7 @@ const stubMirror = Layer.succeed( · 66 unmodified lines
149143 }),
150144 );
151145
152−const layers = Layer.mergeAll(
153− stubApiKeys,
154− stubWorkOS,
155− stubUsers,
156− stubDirectory,
157− stubMirror,
158− stubReadiness,
159−);
146+const layers = Layer.mergeAll(stubApiKeys, stubWorkOS, stubUsers, stubDirectory, stubMirror);
160147
161148 const bearer = (token: string) =>
162149 new Request("https://executor.test/api/tools", {
@@ -224,14 +211,7 @@ describe("org-level API keys", () => { · 61 unmodified lines
224211 const exit = yield* Effect.exit(
225212 resolveBearerAuth(bearer("valid_org_key")).pipe(
226213 Effect.provide(
227− Layer.mergeAll(
228− stubApiKeys,
229− stubWorkOS,
230− deletedOrgUsers,
231− stubDirectory,
232− stubMirror,
233− stubReadiness,
234− ),
214+ Layer.mergeAll(stubApiKeys, stubWorkOS, deletedOrgUsers, stubDirectory, stubMirror),
235215 ),
236216 ),
237217 );
@@ -292,14 +272,7 @@ describe("org-level API keys", () => { · 54 unmodified lines
292272 });
293273 const auth = yield* resolveBearerAuth(bearer("valid_org_key")).pipe(
294274 Effect.provide(
295− Layer.mergeAll(
296− stubApiKeys,
297− stubWorkOS,
298− stubUsers,
299− noMembershipReads,
300− stubMirror,
301− stubReadiness,
302− ),
275+ Layer.mergeAll(stubApiKeys, stubWorkOS, stubUsers, noMembershipReads, stubMirror),
303276 ),
304277 );
305278
apps/cloud/src/auth/org-selector-auth.node.test.ts+31−166
@@ -6,7 +6,6 @@ import { MemberDirectory, type DirectoryMember } from "@executor-js/api/server"; · 5 unmodified lines
66
77 import { ApiKeyService } from "./api-keys";
88 import { UserStoreService } from "./context";
9−import { MirrorReadiness, MirrorReadinessState } from "./mirror-readiness";
109 import { AUTHORIZE_ORGANIZATION_SPAN } from "./organization";
1110 import { resolveSessionPrincipal } from "./workos-auth-provider";
1211 import { WorkOSClient, type WorkOSClientService } from "./workos";
@@ -68,12 +67,6 @@ const memberships = new Map<string, DirectoryMember>([ · 55 unmodified lines
6867 [PENDING_ORG, mirrored(PENDING_ORG, { status: "pending" })],
6968 ]);
7069
71−// The mirror is READY in these tests (backfill complete, reconciler caught
72−// up), so membership is read from the stubbed directory, never from WorkOS.
73−const stubReadiness = Layer.succeed(MirrorReadiness)({
74− state: () => Effect.succeed(MirrorReadinessState.Ready()),
75−});
76−
7770 const stubDirectory = Layer.succeed(MemberDirectory)({
7871 membership: (accountId, organizationId) =>
7972 Effect.succeed(accountId === MEMBER ? (memberships.get(organizationId) ?? null) : null),
@@ -167,85 +160,28 @@ const stubMirror = Layer.succeed( · 87 unmodified lines
167160 }),
168161 );
169162
170−const run = (
171− headers: Record<string, string>,
172− readiness: Layer.Layer<MirrorReadiness> = stubReadiness,
173−) =>
163+const run = (headers: Record<string, string>) =>
174164 resolveSessionPrincipal(new Request("https://executor.test/api/tools", { headers })).pipe(
175− Effect.provide(
176− Layer.mergeAll(stubApiKeys, stubWorkOS, stubUsers, stubDirectory, stubMirror, readiness),
177− ),
165+ Effect.provide(Layer.mergeAll(stubApiKeys, stubWorkOS, stubUsers, stubDirectory, stubMirror)),
178166 );
179167
180−// The mirror before the cutover has landed: the backfill has not covered
181−// every organization, or the reconciler has not drained recently.
182−const unreadyMirror = (state: MirrorReadinessState) =>
183− Layer.succeed(MirrorReadiness)({ state: () => Effect.succeed(state) });
184−
185168 /**
186− * A WorkOS that answers the pre-cutover membership list for MEMBER — active
187− * in SESSION_ORG only, as a member — and records the calls, so the fallback
188− * is assertable: with the mirror unready the list is read from WorkOS and
189− * the directory (which says MEMBER is active in URL_ORG too) is never asked.
190− */
191−const workosMemberships = (calls: string[]) =>
192− Layer.succeed(
193− WorkOSClient,
194− new Proxy({} as WorkOSClientService, {
195− get: (_t, prop) => {
196− if (prop === "authenticateRequest") {
197− return () =>
198− Effect.succeed({
199− userId: MEMBER,
200− email: "u@e2e.test",
201− organizationId: SESSION_ORG,
202− });
203− }
204− if (prop === "listUserMemberships") {
205− return (userId: string) =>
206− Effect.sync(() => {
207− calls.push(`listUserMemberships:${userId}`);
208− return {
209− object: "list" as const,
210− data: [
211− {
212− id: `om_${MEMBER}_${SESSION_ORG}`,
213− userId: MEMBER,
214− organizationId: SESSION_ORG,
215− status: "active",
216− role: { slug: "member" },
217− },
218− ] as never[],
219− listMetadata: { before: null, after: null },
220− };
221− });
222− }
223− return () => Effect.die(`unexpected WorkOSClient.${String(prop)} call`);
224− },
225− }),
226− );
227−
228−const unreadDirectory = Layer.succeed(MemberDirectory)({
229− membership: () => Effect.die("an unready mirror must not be asked for membership"),
230− membershipById: () => Effect.die("an unready mirror must not be asked for membership"),
231− membershipsOf: () => Effect.die("an unready mirror must not be asked for membership"),
232− members: () => Effect.die("an unready mirror must not be asked for membership"),
233− membersById: () => Effect.die("an unready mirror must not be asked for membership"),
234− findByEmail: () => Effect.die("an unready mirror must not be asked for membership"),
235−});
236−
237−/**
238− * A tracer that keeps every span's attributes, so the readiness decision the
239− * authorization stamps on its span (`mirror.ready`, `mirror.readiness`) is
240− * assertable: that attribute is what production counts the fallback by.
169+ * A tracer that keeps every span's attributes, so the authorization span
170+ * itself — {@link AUTHORIZE_ORGANIZATION_SPAN} — is assertable.
241171 */
242172 const makeRecordingTracer = () => {
243− const spans: { readonly name: string; readonly attributes: Map<string, unknown> }[] = [];
173+ const spans: {
174+ readonly name: string;
175+ readonly attributes: Map<string, unknown>;
176+ }[] = [];
244177 const tracer: Tracer.Tracer = {
245178 span: (options) => {
246179 const attributes = new Map<string, unknown>();
247180 spans.push({ name: options.name, attributes });
248− let status: Tracer.SpanStatus = { _tag: "Started", startTime: options.startTime };
181+ let status: Tracer.SpanStatus = {
182+ _tag: "Started",
183+ startTime: options.startTime,
184+ };
249185 return {
250186 _tag: "Span",
251187 name: options.name,
@@ -261,7 +197,12 @@ const makeRecordingTracer = () => { · 9 unmodified lines
261197 sampled: options.sampled,
262198 kind: options.kind,
263199 end: (endTime, exit) => {
264− status = { _tag: "Ended", startTime: options.startTime, endTime, exit };
200+ status = {
201+ _tag: "Ended",
202+ startTime: options.startTime,
203+ endTime,
204+ exit,
205+ };
265206 },
266207 attribute: (key, value) => {
267208 attributes.set(key, value);
@@ -275,24 +216,6 @@ const makeRecordingTracer = () => { · 7 unmodified lines
275216 return { tracer, attributesOf };
276217 };
277218
278−const runAgainstWorkOs = (headers: Record<string, string>, state: MirrorReadinessState) => {
279− const calls: string[] = [];
280− return resolveSessionPrincipal(new Request("https://executor.test/api/tools", { headers }))
281− .pipe(
282− Effect.provide(
283− Layer.mergeAll(
284− stubApiKeys,
285− workosMemberships(calls),
286− stubUsers,
287− unreadDirectory,
288− stubMirror,
289− unreadyMirror(state),
290− ),
291− ),
292− )
293− .pipe(Effect.map((principal) => ({ principal, calls })));
294−};
295−
296219 describe("resolveSessionPrincipal · URL org selector", () => {
297220 it.effect("fails closed when no selector header is sent", () =>
298221 Effect.gen(function* () {
@@ -358,79 +281,21 @@ describe("resolveSessionPrincipal · URL org selector", () => { · 59 unmodified lines
358281 );
359282 });
360283
361−// The mirror authorizes only while it is READY (`mirror-readiness.ts`):
362−// the backfill has written every organization and the reconciler has drained
363−// the stream within its lag budget. Until then the membership check reads
364−// WorkOS, as it did before the cutover — so a member the backfill has not
365−// written yet is not locked out, and a stale mirror row cannot grant access
366−// WorkOS has revoked.
367−describe("resolveSessionPrincipal · mirror readiness", () => {
368− const unready: readonly [string, MirrorReadinessState][] = [
369− ["the backfill has not completed", MirrorReadinessState.BackfillPending()],
370− ["the reconciler has never drained", MirrorReadinessState.ReconcilerStale({ drainedAt: null })],
371− [
372− "the reconciler's last drain is older than the budget",
373− MirrorReadinessState.ReconcilerStale({ drainedAt: createdAt }),
374− ],
375− ];
376−
377− for (const [why, state] of unready) {
378− it.effect(`reads membership from WorkOS, not the mirror, while ${why}`, () =>
379− Effect.gen(function* () {
380− // WorkOS says MEMBER is active in SESSION_ORG: authorized there...
381− const granted = yield* runAgainstWorkOs(
382− { cookie: "wos-session=x", "x-executor-organization": SESSION_ORG },
383− state,
384− );
385− expect(granted.principal.organizationId).toBe(SESSION_ORG);
386− expect(granted.principal.orgRole, "the role comes from WorkOS's list too").toBe("member");
387− expect(granted.calls).toEqual([`listUserMemberships:${MEMBER}`]);
388−
389− // ...and NOT in URL_ORG, even though the (unready) mirror holds an
390− // active admin membership there: the mirror's word is not taken.
391− const refused = yield* Effect.flip(
392− runAgainstWorkOs({ cookie: "wos-session=x", "x-executor-organization": URL_SLUG }, state),
393− );
394− expect(refused).toMatchObject({ _tag: "NoOrganization" });
395− }),
396− );
397− }
398−
399− it.effect("reads the mirror once it is ready, without any WorkOS membership call", () =>
284+// Membership is read from the local mirror unconditionally — there is no
285+// readiness gate and no per-request WorkOS fallback (`auth/organization.ts`).
286+// The span every authorization runs under is still pinned here; a stalled
287+// reconciler is now an operational alert (`workos-events-runner.ts`), not a
288+// request-path branch, so it has no span attribute left to assert on.
289+describe("resolveSessionPrincipal · authorization span", () => {
290+ it.effect("runs the authorization under its span", () =>
400291 Effect.gen(function* () {
401− // `stubWorkOS` dies on any call past session authentication, so a
402− // resolved principal here proves the list was never requested.
403− const principal = yield* run(
404− { cookie: "wos-session=x", "x-executor-organization": URL_SLUG },
405− stubReadiness,
406− );
292+ const recorder = makeRecordingTracer();
293+ const principal = yield* run({
294+ cookie: "wos-session=x",
295+ "x-executor-organization": URL_SLUG,
296+ }).pipe(Effect.withTracer(recorder.tracer));
407297 expect(principal.organizationId).toBe(URL_ORG);
408− expect(principal.orgRole).toBe("admin");
409− }),
410− );
411−
412− it.effect("stamps which source answered on the authorization span", () =>
413− Effect.gen(function* () {
414− // The fallback is counted in production by this attribute, not by
415− // grepping the warning it logs beside it — so both branches must set it.
416− const ready = makeRecordingTracer();
417− yield* run({ cookie: "wos-session=x", "x-executor-organization": URL_SLUG }).pipe(
418− Effect.withTracer(ready.tracer),
419− );
420− const readyAttrs = ready.attributesOf(AUTHORIZE_ORGANIZATION_SPAN);
421− expect(readyAttrs?.get("mirror.ready"), "the mirror answered").toBe(true);
422− expect(readyAttrs?.get("mirror.readiness")).toBe("ready");
423−
424− const stale = makeRecordingTracer();
425− yield* runAgainstWorkOs(
426− { cookie: "wos-session=x", "x-executor-organization": SESSION_ORG },
427− MirrorReadinessState.ReconcilerStale({ drainedAt: null }),
428− ).pipe(Effect.withTracer(stale.tracer));
429− const staleAttrs = stale.attributesOf(AUTHORIZE_ORGANIZATION_SPAN);
430− expect(staleAttrs?.get("mirror.ready"), "WorkOS answered").toBe(false);
431− expect(String(staleAttrs?.get("mirror.readiness")), "and the span says why").toContain(
432− "reconciler",
433− );
298+ expect(recorder.attributesOf(AUTHORIZE_ORGANIZATION_SPAN)).toBeDefined();
434299 }),
435300 );
436301 });
apps/cloud/src/auth/organization.ts+41−99
@@ -18,7 +18,6 @@ import { EXECUTOR_ORG_SELECTOR_HEADER } from "@executor-js/sdk/shared"; · 17 unmodified lines
1818
1919 import { UserStoreService } from "./context";
2020 import { ensureOrganizationBackfilled } from "./mirror-feeders";
21−import { MirrorReadiness, MirrorReadinessState, describeMirrorReadiness } from "./mirror-readiness";
2221 import type { Organization } from "./user-store";
2322 import { WorkOSClient } from "./workos";
2423
@@ -94,12 +93,11 @@ export const markOrganizationDeleted = (organizationId: string) => · 69 unmodified lines
9493 // until their access token naturally expired (~10 min) if the session were
9594 // trusted on its own.
9695 //
97−// To close that gap, membership is verified on every protected request — but
96+// To close that gap, membership is verified on every protected request
9897 // against the LOCAL mirror of WorkOS memberships (`memberships` join
99−// `accounts`, read through the shared `MemberDirectory`), never against WorkOS
100−// itself. This used to be one `listUserMemberships` call per request (2026-07:
101−// deliberately NOT cached, because a positive TTL cache is exactly what would
102−// re-open the revocation gap). The mirror is not a cache with a TTL; it is a
98+// `accounts`, read through the shared `MemberDirectory`) — never against
99+// WorkOS itself, and unconditionally: there is no readiness gate and no
100+// per-request WorkOS fallback. The mirror is not a cache with a TTL; it is a
103101 // replica whose freshness is defined by its feeders:
104102 // - login (`auth/handlers.ts` callback): the user and every membership WorkOS
105103 // lists for them, from the list the callback already fetches;
@@ -117,45 +115,39 @@ export const markOrganizationDeleted = (organizationId: string) => · 11 unmodified lines
117115 // not finish refuses every session at once — its membership rows are still
118116 // there, live, until the purge removes them, and must not authorize anyone.
119117 //
120−// The mirror is trusted only while it is READY (`mirror-readiness.ts`): the
121−// one-off backfill has written every organization, and the events reconciler
122−// has drained the stream within its lag budget. Until both hold, membership is
123−// read from WorkOS (`listUserMemberships`, one call per request) exactly as
124−// before the cutover — a member the backfill has not written yet must not be
125−// locked out, and a member revoked in the dashboard while the reconciler was
126−// down must not be let in on a stale row. The readiness row is one indexed
127−// point read on the same socket; the deploy gate
128−// (`scripts/ensure-workos-mirror-ready.ts`) applies the same rule before this
129−// build goes live, so in steady state the fallback is never taken. A
130−// readiness or mirror read failure fails the request (500), never a silent
131−// fallback in either direction. The one org the fallback never asks WorkOS
132−// about is one the mirror holds as DELETED: WorkOS no longer has it (or is
133−// about to not), so its answer is "no member" for everyone — including the
134−// admin whose deletion failed part-way and must retry it (below). That
135−// membership is read from the mirror, whose rows are exactly what the purge
136−// has not removed yet, ready or not; a refused caller gets null either way.
118+// The one-off backfill is complete and permanent, and an organization that
119+// predates it is covered on demand (below), so there is nothing left for a
120+// per-request readiness check to gate. What can still go wrong is the events
121+// reconciler falling behind — a member revoked in the WorkOS dashboard would
122+// keep a stale active row until it catches up. That is now an OPERATIONAL
123+// concern, not a request-path fallback: the reconciler itself
124+// (`workos-events-runner.ts`) checks its own drain lag after every run and
125+// raises a Sentry error when it has stalled, so it is fixed by paging someone,
126+// not by asking WorkOS on every request. The deploy gate
127+// (`scripts/ensure-workos-mirror-ready.ts`) separately refuses to ship a build
128+// that trusts the mirror while it is unready, using the same rule
129+// (`mirror-readiness-store.ts`).
137130 //
138−// Readiness is database-wide; completeness is PER ORGANIZATION. An
139−// organization whose row was minted after the backfill ran — lazily by a
140−// request (`resolveOrganization`), or by a first login — carries no
141−// `backfilled_at`, and the mirror holds only the memberships login and
142−// write-through happened to record for it: a member who has not signed in
143−// since would be refused on a row that was never written. So the org row is
144−// read FIRST, and an unmarked live organization is scanned from WorkOS
145−// (`ensureOrganizationBackfilled`: one membership listing plus one `getUser`
146−// per member, then the mark) BEFORE its mirror is read — the same on-demand
147−// scan the seat gates run. One-time per organization: the scan marks the
148−// row, and this branch is never taken for it again. An organization the
149−// mirror does not hold at all — one that predates the mirror and that nobody
150−// has signed in to since (a CLI or MCP token names it, and the JWT path has
151−// no login feeder), or one created in the WorkOS dashboard — is reachable by
152−// neither the backfill (which lists the mirror's organizations) nor the
153−// reconciler (which starts at the replay boundary), so it is resolved on
154−// demand HERE: WorkOS is asked for the caller's own membership in it first
155−// (`getUserOrgMembership`, a read scoped to this caller — never a listing
156−// of the org), and only a member's answer mints the row
157−// (`resolveOrganization`) and scans it as above. A non-member mints nothing:
158−// a signed-in caller cannot create the row of an arbitrary WorkOS
131+// Completeness is PER ORGANIZATION. An organization whose row was minted
132+// after the backfill ran — lazily by a request (`resolveOrganization`), or by
133+// a first login — carries no `backfilled_at`, and the mirror holds only the
134+// memberships login and write-through happened to record for it: a member
135+// who has not signed in since would be refused on a row that was never
136+// written. So the org row is read FIRST, and an unmarked live organization is
137+// scanned from WorkOS (`ensureOrganizationBackfilled`: one membership listing
138+// plus one `getUser` per member, then the mark) BEFORE its mirror is read —
139+// the same on-demand scan the seat gates run. One-time per organization: the
140+// scan marks the row, and this branch is never taken for it again. An
141+// organization the mirror does not hold at all — one that predates the
142+// mirror and that nobody has signed in to since (a CLI or MCP token names it,
143+// and the JWT path has no login feeder), or one created in the WorkOS
144+// dashboard — is reachable by neither the backfill (which lists the mirror's
145+// organizations) nor the reconciler (which starts at the replay boundary), so
146+// it is resolved on demand HERE: WorkOS is asked for the caller's own
147+// membership in it first (`getUserOrgMembership`, a read scoped to this
148+// caller — never a listing of the org), and only a member's answer mints the
149+// row (`resolveOrganization`) and scans it as above. A non-member mints
150+// nothing: a signed-in caller cannot create the row of an arbitrary WorkOS
159151 // organization by naming its id. An organization marked deleted is never
160152 // scanned: WorkOS no longer has it, and its rows are the purge's to remove,
161153 // not a listing's to refresh.
@@ -189,20 +181,6 @@ const activeMembershipFromMirror = (userId: string, organizationId: string) => · 27 unmodified lines
189181 return active;
190182 });
191183
192−// The pre-cutover read, kept for the window in which the mirror is not yet
193−// ready: WorkOS's own membership list for the user, one call per request.
194−const activeMembershipFromWorkOs = (userId: string, organizationId: string) =>
195− Effect.gen(function* () {
196− const workos = yield* WorkOSClient;
197− const memberships = yield* workos.listUserMemberships(userId);
198− const membership = memberships.data.find(
199− (m) => m.organizationId === organizationId && m.status === "active",
200− );
201− if (!membership) return null;
202− const active: ActiveMembership = { role: membership.role.slug };
203− return active;
204− });
205−
206184 // The authorized organization, or null for one marked deleted (unless the
207185 // caller is the deletion flow). The membership already names the caller's
208186 // role — surfaced normalized so identity resolution can bind the executor's
@@ -236,14 +214,7 @@ const heldOrResolvedForMember = (userId: string, organizationId: string) => · 27 unmodified lines
236214 return yield* resolveOrganization(organizationId);
237215 });
238216
239−/**
240− * The span every membership authorization runs under, with the readiness
241− * decision stamped on it so the fallback can be counted rather than grepped:
242− * `mirror.ready` (boolean, which source answered) and `mirror.readiness` (the
243− * state's description, why). Query Axiom for `name == "auth.authorize_organization"`
244− * and `mirror.ready == false` to see how many requests are on the WorkOS
245− * fallback and for which reason.
246− */
217+/** The span every membership authorization runs under. */
247218 export const AUTHORIZE_ORGANIZATION_SPAN = "auth.authorize_organization";
248219
249220 export const authorizeOrganization = (
@@ -252,43 +223,14 @@ export const authorizeOrganization = ( · 2 unmodified lines
252223 options: AuthorizeOrganizationOptions = {},
253224 ) =>
254225 Effect.gen(function* () {
255− const readiness = yield* MirrorReadiness;
256− const state = yield* readiness.state();
257− const ready = MirrorReadinessState.$is("Ready")(state);
258− yield* Effect.annotateCurrentSpan({
259− "mirror.ready": ready,
260− "mirror.readiness": describeMirrorReadiness(state),
261− });
262− if (!ready) {
263− yield* Effect.logWarning(
264− "authorizeOrganization: membership mirror not ready; membership read from WorkOS",
265− { readiness: describeMirrorReadiness(state) },
266− );
267− // A marked organization is the mirror's to answer for (see above):
268− // WorkOS lists no member of it, and the deletion retry must still
269− // get in.
270− const users = yield* UserStoreService;
271− const held = yield* users.use("getOrganization", (s) => s.getOrganization(organizationId));
272− if (held?.deletedAt != null) {
273− if (options.deleted !== "allow") return null;
274− const membership = yield* activeMembershipFromMirror(userId, organizationId);
275− if (!membership) return null;
276− return authorized(held, membership, options);
277− }
278− const membership = yield* activeMembershipFromWorkOs(userId, organizationId);
279− if (!membership) return null;
280− // The row read above is reused: `resolveOrganization` would read it a
281− // second time, and a request reads the organization row ONCE (the MCP
282− // session DO relies on that — see e2e `mcp-session-cold-init`).
283− const org = held ?? (yield* resolveOrganization(organizationId));
284− return authorized(org, membership, options);
285− }
286−
287226 const org = yield* heldOrResolvedForMember(userId, organizationId);
288227 if (!org) return null;
289228 // An unmarked live organization is scanned before its mirror is read
290229 // (see above). The row returned below still shows the mark as it was
291− // read; nothing past this point reads it.
230+ // read; nothing past this point reads it. A marked-deleted organization
231+ // is never scanned, so the deletion retry (`deleted: "allow"`) reaches
232+ // `authorized()` below with the membership row the purge has not removed
233+ // yet.
292234 if (org.deletedAt === null && org.backfilledAt === null) {
293235 yield* ensureOrganizationBackfilled(organizationId);
294236 }
apps/cloud/src/auth/workos-auth-provider.ts+15−21
@@ -20,16 +20,16 @@ · 19 unmodified lines
2020 // - session org not authorized -> NoOrganization 403 no_organization
2121 // - no auth header -> falls through to the sealed-session path
2222 // The org-resolution infra errors (`UserStoreError` / `WorkOSError` /
23−// `MemberDirectoryError` / `WorkOsMirrorError`, the last from the mirror
24−// readiness read) are `Effect.die`d so they surface as 500 defects — the
25−// same status the old inline resolver produced when those bubbled up.
23+// `MemberDirectoryError` / `WorkOsMirrorError`) are `Effect.die`d so they
24+// surface as 500 defects — the same status the old inline resolver produced
25+// when those bubbled up.
2626 //
27−// The per-request `UserStoreService` + `MemberDirectory` + `MirrorReadiness`
28−// + `WorkOsMirror` (read by the org-resolution path: the org row, whether
29−// the mirror may be trusted, the caller's mirrored membership, and the
30−// on-demand scan of an organization the backfill never covered) stay
31−// REQUIREMENTS OF THE LAYER, satisfied by the facade's per-request DB combine —
32−// NOT function-level requirements (that is what forced a forked tag before).
27+// The per-request `UserStoreService` + `MemberDirectory` + `WorkOsMirror`
28+// (read by the org-resolution path: the org row, the caller's mirrored
29+// membership, and the on-demand scan of an organization the backfill never
30+// covered) stay REQUIREMENTS OF THE LAYER, satisfied by the facade's
31+// per-request DB combine — NOT function-level requirements (that is what
32+// forced a forked tag before).
3333 // ---------------------------------------------------------------------------
3434
3535 import { Effect, Layer } from "effect";
@@ -54,7 +54,6 @@ import type { · 18 unmodified lines
5454
5555 import { ApiKeyService } from "./api-keys";
5656 import { workosApiJwtBearerConfig } from "./api-jwt-bearer";
57−import { MirrorReadiness } from "./mirror-readiness";
5857 import { WorkOsMirror } from "./workos-mirror";
5958 import { BEARER_PREFIX } from "./bearer";
6059 import {
@@ -215,7 +214,7 @@ export const resolveBearerAuth = ( · 154 unmodified lines
215214 | WorkOSError
216215 | WorkOsMirrorError
217216 | MemberDirectoryError,
218− WorkOSClient | ApiKeyService | UserStoreService | MemberDirectory | MirrorReadiness | WorkOsMirror
217+ WorkOSClient | ApiKeyService | UserStoreService | MemberDirectory | WorkOsMirror
219218 > =>
220219 Effect.gen(function* () {
221220 const authHeader = request.headers.get("authorization");
@@ -301,7 +300,7 @@ export const resolveApiKeyPrincipal = ( · 79 unmodified lines
301300 | WorkOSError
302301 | WorkOsMirrorError
303302 | MemberDirectoryError,
304− WorkOSClient | ApiKeyService | UserStoreService | MemberDirectory | MirrorReadiness | WorkOsMirror
303+ WorkOSClient | ApiKeyService | UserStoreService | MemberDirectory | WorkOsMirror
305304 > =>
306305 Effect.gen(function* () {
307306 const auth = yield* resolveBearerAuth(request, jwt);
@@ -383,7 +382,7 @@ export const resolveProtectedPrincipal = ( · 75 unmodified lines
383382 | WorkOSError
384383 | WorkOsMirrorError
385384 | MemberDirectoryError,
386− WorkOSClient | ApiKeyService | UserStoreService | MemberDirectory | MirrorReadiness | WorkOsMirror
385+ WorkOSClient | ApiKeyService | UserStoreService | MemberDirectory | WorkOsMirror
387386 > =>
388387 Effect.gen(function* () {
389388 const bearerPrincipal = yield* resolveApiKeyPrincipal(request, jwt);
@@ -404,17 +403,12 @@ export const resolveProtectedPrincipal = ( · 14 unmodified lines
404403 export const workosIdentityLayer: Layer.Layer<
405404 IdentityProvider,
406405 never,
407− WorkOSClient | ApiKeyService | UserStoreService | MemberDirectory | MirrorReadiness | WorkOsMirror
406+ WorkOSClient | ApiKeyService | UserStoreService | MemberDirectory | WorkOsMirror
408407 > = Layer.effect(
409408 IdentityProvider,
410409 Effect.gen(function* () {
411410 const context = yield* Effect.context<
412− | WorkOSClient
413− | ApiKeyService
414− | UserStoreService
415− | MemberDirectory
416− | MirrorReadiness
417− | WorkOsMirror
411+ WorkOSClient | ApiKeyService | UserStoreService | MemberDirectory | WorkOsMirror
418412 >();
419413 return IdentityProvider.of({
420414 authenticate: (request) =>
@@ -430,7 +424,7 @@ export const workosIdentityLayer: Layer.Layer< · 9 unmodified lines
430424 WorkOSError: (error) => Effect.die(error),
431425 // oxlint-disable-next-line executor/no-effect-escape-hatch -- boundary: membership-mirror read failure -> 500 defect, same class as the store failure above
432426 MemberDirectoryError: (error) => Effect.die(error),
433− // oxlint-disable-next-line executor/no-effect-escape-hatch -- boundary: mirror-readiness read failure -> 500 defect, same class as the store failure above
427+ // oxlint-disable-next-line executor/no-effect-escape-hatch -- boundary: membership-mirror read failure -> 500 defect, same class as the store failure above
434428 WorkOsMirrorError: (error) => Effect.die(error),
435429 }),
436430 Effect.provide(context),
apps/cloud/src/auth/workos-events-runner.node.test.ts+81−0
@@ -0,0 +1,81 @@
1+// ---------------------------------------------------------------------------
2+// A reconciler run that does not end `"drained"` can still leave the mirror
3+// fresh (another run drained it moments ago) or leave it stale (nothing has
4+// drained inside the lag budget). `alertOnStaleReconciler` is the ONLY place
5+// that distinction is now reported — the request path no longer reads
6+// readiness at all — so this pins both branches directly against it: a
7+// fresh `drainedAt` logs nothing, and a stale or absent `drainedAt` logs a
8+// structured error. `captureCauseEffect` is not swapped out: it calls
9+// `Sentry.captureException` directly, is a no-op in this uninitialized test
10+// environment, and its call path is exercised for real rather than mocked.
11+// ---------------------------------------------------------------------------
12+
13+import { describe, expect, it } from "@effect/vitest";
14+import { Cause, Duration, Effect, Layer, Logger } from "effect";
15+
16+import { WorkOsMirror, type WorkOsMirrorShape } from "./workos-mirror";
17+import { alertOnStaleReconciler } from "./workos-events-runner";
18+import type { WorkOsEventsSyncReport } from "./workos-events-sync";
19+
20+const capturingLogger = (sink: Array<string>) =>
21+ Logger.make<unknown, void>((options) => {
22+ sink.push(String(options.message));
23+ sink.push(Cause.pretty(options.cause));
24+ });
25+
26+const reportEndingWith = (stopped: WorkOsEventsSyncReport["stopped"]): WorkOsEventsSyncReport => ({
27+ pages: 1,
28+ events: 0,
29+ applied: 0,
30+ stale: 0,
31+ absent: 0,
32+ stopped,
33+ cursor: null,
34+});
35+
36+const stubMirror = (drainedAt: Date | null) =>
37+ Layer.succeed(
38+ WorkOsMirror,
39+ new Proxy({} as WorkOsMirrorShape, {
40+ get: (_target, prop) => {
41+ if (prop === "drainedAt") return () => Effect.succeed(drainedAt);
42+ return () => Effect.die(`unexpected WorkOsMirror.${String(prop)} call`);
43+ },
44+ }),
45+ );
46+
47+const run = (report: WorkOsEventsSyncReport, drainedAt: Date | null) => {
48+ const logged: string[] = [];
49+ return Effect.runPromise(
50+ alertOnStaleReconciler(report).pipe(
51+ Effect.provide(stubMirror(drainedAt)),
52+ Effect.provide(Logger.layer([capturingLogger(logged)])),
53+ ),
54+ ).then(() => logged);
55+};
56+
57+describe("alertOnStaleReconciler", () => {
58+ it("does not alert when the run itself drained the stream", async () => {
59+ // A `"drained"` run just called `markDrained`, so it is healthy by
60+ // definition and `drainedAt` is never read.
61+ const logged = await run(reportEndingWith("drained"), null);
62+ expect(logged).toEqual([]);
63+ });
64+
65+ it("does not alert when the mirror drained inside the lag budget", async () => {
66+ const fresh = new Date(Date.now() - Duration.toMillis(Duration.minutes(1)));
67+ const logged = await run(reportEndingWith("page_budget"), fresh);
68+ expect(logged).toEqual([]);
69+ });
70+
71+ it("alerts when the mirror has not drained inside the lag budget", async () => {
72+ const stale = new Date(Date.now() - Duration.toMillis(Duration.minutes(11)));
73+ const logged = await run(reportEndingWith("page_budget"), stale);
74+ expect(logged.some((line) => line.includes("workos_events: reconciler stale"))).toBe(true);
75+ });
76+
77+ it("alerts when the mirror has never drained", () =>
78+ run(reportEndingWith("awaiting_backfill"), null).then((logged) => {
79+ expect(logged.some((line) => line.includes("workos_events: reconciler stale"))).toBe(true);
80+ }));
81+});
apps/cloud/src/auth/workos-events-runner.ts+59−3
@@ -13,17 +13,28 @@ · 12 unmodified lines
1313 // A failing run is captured (Sentry + structured log) and swallowed here:
1414 // neither entry has a caller to report to, and the run is retried by the
1515 // next cron tick from the last committed cursor.
16+//
17+// A run that does not fail outright can still leave the mirror stale: one
18+// that stops short of `"drained"` (page budget, cursor contention) means the
19+// reconciler did not catch up this tick, and one stuck on
20+// `"awaiting_backfill"` past the budget means the boundary row the backfill
21+// was supposed to hand off was lost — both are read from `drainedAt` after
22+// the run and, past `MIRROR_RECONCILER_LAG_BUDGET`, reported the same way a
23+// thrown failure is: a structured error log plus a Sentry capture. This is
24+// the ONLY place a stale reconciler surfaces now; the request path
25+// (`auth/organization.ts`) no longer checks readiness or falls back.
1626 // ---------------------------------------------------------------------------
1727
18−import { Effect, Layer } from "effect";
28+import { Clock, Data, Duration, Effect, Layer } from "effect";
1929
2030 import { captureCauseEffect } from "../observability";
2131 import { WorkerTelemetryLive } from "../observability/telemetry";
2232 import { makeDbLayer } from "../db/db";
2333 import { makeUserStoreLayer } from "./context";
34+import { MIRROR_RECONCILER_LAG_BUDGET } from "./mirror-readiness-store";
2435 import { CoreSharedServices } from "./workos";
25−import { syncWorkOsEvents } from "./workos-events-sync";
26−import { makeWorkOsMirrorLayer } from "./workos-mirror";
36+import { syncWorkOsEvents, type WorkOsEventsSyncReport } from "./workos-events-sync";
37+import { makeWorkOsMirrorLayer, WorkOsMirror } from "./workos-mirror";
2738
2839 const makeSyncServices = () => {
2940 const dbLive = makeDbLayer();
@@ -34,14 +45,59 @@ const makeSyncServices = () => { · 4 unmodified lines
3445 );
3546 };
3647
48+const LAG_BUDGET_MS = Duration.toMillis(MIRROR_RECONCILER_LAG_BUDGET);
49+
50+/**
51+ * The mirror has not drained the WorkOS events stream inside its lag budget.
52+ * Its own tagged error so Sentry groups every occurrence under one issue,
53+ * with the run's outcome and the heartbeat's age as the fields to read.
54+ */
55+export class WorkOsReconcilerStale extends Data.TaggedError("WorkOsReconcilerStale")<{
56+ readonly stopped: WorkOsEventsSyncReport["stopped"];
57+ readonly drainedAt: string | null;
58+ readonly ageMs: number | null;
59+}> {}
60+
61+/**
62+ * After a run that did not end `"drained"`, check whether the mirror has
63+ * fallen behind its lag budget and, if so, report it the way a failed run is
64+ * reported: a structured error log plus a Sentry capture. A run ending
65+ * `"drained"` just wrote `markDrained` and is healthy by definition, so it is
66+ * never checked. Cheap by design: one `drainedAt` read per run (about once a
67+ * minute).
68+ */
69+export const alertOnStaleReconciler = Effect.fn("workos_events.alert_on_stale_reconciler")(
70+ function* (report: WorkOsEventsSyncReport) {
71+ if (report.stopped === "drained") return;
72+
73+ const mirror = yield* WorkOsMirror;
74+ const drainedAt = yield* mirror.drainedAt();
75+ const now = yield* Clock.currentTimeMillis;
76+ const ageMs = drainedAt === null ? null : now - drainedAt.getTime();
77+ if (ageMs !== null && ageMs <= LAG_BUDGET_MS) return;
78+
79+ const stale = new WorkOsReconcilerStale({
80+ stopped: report.stopped,
81+ drainedAt: drainedAt === null ? null : drainedAt.toISOString(),
82+ ageMs,
83+ });
84+ yield* Effect.logError("workos_events: reconciler stale", stale);
85+ yield* captureCauseEffect(stale);
86+ },
87+);
88+
3789 /**
3890 * One reconciler pass over fresh request-scoped services. Resolves when the
3991 * pass ends, whether it drained the stream, stopped at the page budget,
4092 * yielded to another run, or failed (a failure is reported, never thrown).
93+ * A run that ends short of `"drained"` and leaves `drainedAt` past the lag
94+ * budget is ALSO reported (see {@link alertOnStaleReconciler}) — a stalled
95+ * reconciler is now an operational alert, not a per-request fallback.
4196 */
4297 export const runWorkOsEventsSync = (): Promise<void> =>
4398 Effect.runPromise(
4499 syncWorkOsEvents().pipe(
100+ Effect.tap((report) => alertOnStaleReconciler(report)),
45101 Effect.asVoid,
46102 Effect.provide(makeSyncServices()),
47103 Effect.scoped,
apps/cloud/src/auth/workos-events-sync.node.test.ts+35−21
@@ -62,7 +62,6 @@ import { UserStoreService } from "./context"; · 61 unmodified lines
6262 import { WorkOSError } from "./errors";
6363 import { cloudMemberDirectoryLayer } from "./member-directory";
6464 import { mirrorSignIn } from "./mirror-feeders";
65−import { MirrorReadiness, MirrorReadinessState } from "./mirror-readiness";
6665 import { authorizeOrganization } from "./organization";
6766 import { WorkOSClient, type WorkOSClientService, type WorkOSListEventsOptions } from "./workos";
6867 import {
@@ -202,26 +201,14 @@ const profiles = (reads: string[] = []): Partial<WorkOSClientService> => ({ · 133 unmodified lines
202201 });
203202
204203 const DbLive = DbService.Live;
205−// The mirror is READY here (the authorization checks below read the mirror,
206−// not WorkOS); the readiness rule is pinned in workos-mirror.node.test.ts.
207−const readyMirror = Layer.succeed(MirrorReadiness)({
208− state: () => Effect.succeed(MirrorReadinessState.Ready()),
209−});
210−
204+// The authorization checks below always read the mirror, never WorkOS.
211205 const MirrorServices = Layer.mergeAll(
212206 WorkOsMirror.Live,
213207 UserStoreService.Live,
214208 cloudMemberDirectoryLayer,
215− readyMirror,
216209 ).pipe(Layer.provideMerge(DbLive));
217210
218−type Services =
219− | WorkOsMirror
220− | UserStoreService
221− | MemberDirectory
222− | MirrorReadiness
223− | DbService
224− | WorkOSClient;
211+type Services = WorkOsMirror | UserStoreService | MemberDirectory | DbService | WorkOSClient;
225212
226213 const run = <A, E>(
227214 body: Effect.Effect<A, E, Services>,
@@ -549,7 +536,10 @@ describe("applyEvent", () => { · 321 unmodified lines
549536 const promoted = yield* applyEvent(
550537 membershipEvent(
551538 "organization_membership.updated",
552− workosMembership(joiner, org, { role: { slug: "admin" }, updatedAt: T2 }),
539+ workosMembership(joiner, org, {
540+ role: { slug: "admin" },
541+ updatedAt: T2,
542+ }),
553543 ),
554544 );
555545 // A member WorkOS no longer has (their `user.deleted` is further down
@@ -558,7 +548,15 @@ describe("applyEvent", () => { · 2 unmodified lines
558548 membershipEvent("organization_membership.created", workosMembership(gone, org)),
559549 );
560550 const goneRow = yield* readMembership(gone, org);
561− return { knownJoins, knownRow, joins, joinerRow, promoted, goneJoins, goneRow };
551+ return {
552+ knownJoins,
553+ knownRow,
554+ joins,
555+ joinerRow,
556+ promoted,
557+ goneJoins,
558+ goneRow,
559+ };
562560 }),
563561 stubWorkOS({
564562 getUser: (userId) =>
@@ -581,7 +579,10 @@ describe("applyEvent", () => { · 16 unmodified lines
581579 expect(result.joinerRow?.email).toBe(`${joiner}@placeholder.test`);
582580 expect(result.promoted).toBe("applied");
583581 expect(result.goneJoins, "a member WorkOS no longer has is still mirrored").toBe("applied");
584− expect(result.goneRow).toMatchObject({ membershipId: `om_${gone}_${org}`, name: null });
582+ expect(result.goneRow).toMatchObject({
583+ membershipId: `om_${gone}_${org}`,
584+ name: null,
585+ });
585586 expect(reads, "one read per unprofiled member, none for a profiled one").toEqual([
586587 joiner,
587588 gone,
@@ -601,7 +602,9 @@ describe("applyEvent", () => { · 13 unmodified lines
601602 Effect.provide(
602603 Layer.mergeAll(
603604 MirrorServices,
604− stubWorkOS({ getUser: () => Effect.fail(new WorkOSError({ status: 503 })) }),
605+ stubWorkOS({
606+ getUser: () => Effect.fail(new WorkOSError({ status: 503 })),
607+ }),
605608 ),
606609 ),
607610 Effect.scoped,
@@ -731,7 +734,11 @@ describe("applyEvent", () => { · 123 unmodified lines
731734 // deletion, and stalled past it now writes what it holds.
732735 yield* mirrorSignIn(
733736 workosUser(userId),
734− [workosMembership(userId, org, { organizationName: "Never Mirrored" })],
737+ [
738+ workosMembership(userId, org, {
739+ organizationName: "Never Mirrored",
740+ }),
741+ ],
735742 new Date(T1),
736743 );
737744 const afterLogin = yield* readOrganization(org);
@@ -1108,7 +1115,14 @@ describe("syncWorkOsEvents", () => { · 370 unmodified lines
11081115 const cursor = yield* mirror.getCursor();
11091116 const membership = yield* readMembership(userId, org);
11101117 const drainedAfter = yield* mirror.drainedAt();
1111− return { report, cursor, intruder, membership, drainedBefore, drainedAfter };
1118+ return {
1119+ report,
1120+ cursor,
1121+ intruder,
1122+ membership,
1123+ drainedBefore,
1124+ drainedAfter,
1125+ };
11121126 }),
11131127 );
11141128 expect(requests, "the second page is never read").toHaveLength(1);
apps/cloud/src/auth/workos-mirror.node.test.ts+37−14
@@ -63,11 +63,10 @@ import { · 62 unmodified lines
6363 import { makeWorkOsMirrorStore } from "./workos-mirror-store";
6464 import {
6565 MIRROR_RECONCILER_LAG_BUDGET,
66− MirrorReadiness,
6766 MirrorReadinessState,
68− makeMirrorReadinessLayer,
6967 mirrorReadinessFrom,
70−} from "./mirror-readiness";
68+ readMirrorReadiness,
69+} from "./mirror-readiness-store";
7170
7271 const DbLive = DbService.Live;
7372 const Services = Layer.mergeAll(
@@ -243,7 +242,11 @@ describe("WorkOsMirror upserts", () => { · 169 unmodified lines
243242 // the row holds — the payload a timestamp guard would let through.
244243 // Same id: the membership is deleted, it never returns.
245244 const newerSameId = yield* mirror.upsertMembership(
246− membership(org, id, { id: membershipId, role: "admin", updatedAt: T3 }),
245+ membership(org, id, {
246+ id: membershipId,
247+ role: "admin",
248+ updatedAt: T3,
249+ }),
247250 );
248251 const afterNewerSameId = yield* directory.membership(id, org, ["inactive"]);
249252 // The member re-added in WorkOS: a payload newer than the deletion,
@@ -299,7 +302,10 @@ describe("WorkOsMirror upserts", () => { · 49 unmodified lines
299302 result.newerSameId,
300303 "a payload of the deleted id stamped AFTER the removal is refused: identity, not time",
301304 ).toBe(false);
302− expect(result.afterNewerSameId).toMatchObject({ status: "inactive", role: "member" });
305+ expect(result.afterNewerSameId).toMatchObject({
306+ status: "inactive",
307+ role: "member",
308+ });
303309 expect(result.readded, "a replacement under a new id reactivates").toBe(true);
304310 expect(result.afterReadd?.status).toBe("active");
305311 expect(result.lateDelete, "a replayed deletion of the OLD id is refused").toBe(false);
@@ -516,7 +522,11 @@ describe("WorkOsMirror upserts", () => { · 210 unmodified lines
516522 // with status inactive): the row is inactive but still WorkOS's, with
517523 // no `deleted_at` to protect it.
518524 const deactivated = yield* mirror.upsertMembership(
519− membership(org, id, { id: membershipId, status: "inactive", updatedAt: T2 }),
525+ membership(org, id, {
526+ id: membershipId,
527+ status: "inactive",
528+ updatedAt: T2,
529+ }),
520530 );
521531 const whileInactive = yield* directory.membership(id, org);
522532 // A payload older than the deactivation cannot undo it, nor one
@@ -529,7 +539,11 @@ describe("WorkOsMirror upserts", () => { · 6 unmodified lines
529539 );
530540 // WorkOS reactivates it, same id, newer stamp: live again.
531541 const reactivated = yield* mirror.upsertMembership(
532− membership(org, id, { id: membershipId, role: "admin", updatedAt: T3 }),
542+ membership(org, id, {
543+ id: membershipId,
544+ role: "admin",
545+ updatedAt: T3,
546+ }),
533547 );
534548 const afterReactivate = yield* directory.membership(id, org);
535549 return {
@@ -634,7 +648,10 @@ describe("WorkOsMirror upserts", () => { · 98 unmodified lines
634648 result.rejoined,
635649 "a membership of a deleted user is refused however it is stamped: identity, not time",
636650 ).toBe(false);
637− expect(result.afterRejoin).toMatchObject({ status: "inactive", name: null });
651+ expect(result.afterRejoin).toMatchObject({
652+ status: "inactive",
653+ name: null,
654+ });
638655 expect(result.unknown, "deleting an unseen user leaves a tombstone").toBe(true);
639656 expect(result.unseenProfile, "which the older profile cannot fill").toBe(false);
640657 expect(
@@ -883,19 +900,20 @@ describe("mirror readiness", () => { · 242 unmodified lines
883900 const result = await run(
884901 Effect.gen(function* () {
885902 const mirror = yield* WorkOsMirror;
886− const readiness = yield* MirrorReadiness;
903+ const { db } = yield* DbService;
904+ const readiness = () => Effect.promise(() => readMirrorReadiness(db, new Date()));
887905 yield* clearEventsRow;
888− const noRow = yield* readiness.state();
906+ const noRow = yield* readiness();
889907 yield* mirror.setReplayBoundary(T1);
890908 yield* mirror.markBackfillCompleted(T1);
891− const backfilledOnly = yield* readiness.state();
909+ const backfilledOnly = yield* readiness();
892910 // A drain as of now: what a reconciler run that just read the stream
893911 // to its end records.
894912 const drainedAt = new Date();
895913 yield* mirror.markDrained(drainedAt);
896− const ready = yield* readiness.state();
914+ const ready = yield* readiness();
897915 return { noRow, backfilledOnly, ready };
898− }).pipe(Effect.provide(makeMirrorReadinessLayer().pipe(Layer.provide(DbLive)))),
916+ }),
899917 );
900918 expect(result.noRow).toEqual(MirrorReadinessState.BackfillPending());
901919 expect(result.backfilledOnly).toEqual(
@@ -1006,7 +1024,12 @@ describe("WorkOsMirror backfill sync state", () => { · 104 unmodified lines
10061024 yield* mirror.applyOrganizationScan({
10071025 organizationId: org,
10081026 listedAt: T2,
1009− members: [{ user: user(kept), membership: membership(org, kept, { updatedAt: T1 }) }],
1027+ members: [
1028+ {
1029+ user: user(kept),
1030+ membership: membership(org, kept, { updatedAt: T1 }),
1031+ },
1032+ ],
10101033 });
10111034 // The stalled login resumes and writes what it holds: refused, the
10121035 // revocation predates the scan and nothing would ever undo the row.
apps/cloud/src/extensions/billing/route.node.test.ts+1−10
@@ -4,7 +4,6 @@ import { Effect, Layer } from "effect"; · 3 unmodified lines
44 import { MemberDirectory } from "@executor-js/api/server";
55
66 import { UserStoreService } from "../../auth/context";
7−import { MirrorReadiness, MirrorReadinessState } from "../../auth/mirror-readiness";
87 import { WorkOSClient, type WorkOSClientService } from "../../auth/workos";
98 import { WorkOsMirror, type WorkOsMirrorShape } from "../../auth/workos-mirror";
109 import { resolveBillingOrganization } from "./route";
@@ -40,12 +39,6 @@ const stubWorkOS = Layer.succeed( · 29 unmodified lines
4039 );
4140
4241 // MEMBER is active in both orgs, as the mirror reports it.
43−// The mirror is READY in these tests (backfill complete, reconciler caught
44−// up), so membership is read from the stubbed directory, never from WorkOS.
45−const stubReadiness = Layer.succeed(MirrorReadiness)({
46− state: () => Effect.succeed(MirrorReadinessState.Ready()),
47−});
48−
4942 const stubDirectory = Layer.succeed(MemberDirectory)({
5043 membership: (accountId, organizationId) =>
5144 Effect.succeed(
@@ -122,9 +115,7 @@ const run = (headers: Record<string, string>) => · 70 unmodified lines
122115 resolveBillingOrganization(
123116 new Request("https://executor.test/api/billing/customer", { headers }),
124117 { userId: MEMBER },
125− ).pipe(
126− Effect.provide(Layer.mergeAll(stubWorkOS, stubUsers, stubDirectory, stubMirror, stubReadiness)),
127− );
118+ ).pipe(Effect.provide(Layer.mergeAll(stubWorkOS, stubUsers, stubDirectory, stubMirror)));
128119
129120 describe("billing route org selector", () => {
130121 it.effect("fails closed when no selector header is sent", () =>
apps/cloud/src/extensions/routes.ts+4−5
@@ -31,7 +31,6 @@ import { AccountApi, AdminUsersApi } from "@executor-js/api"; · 30 unmodified lines
3131 import { requestScopedMiddleware, type MemberDirectory } from "@executor-js/api/server";
3232
3333 import { UserStoreService } from "../auth/context";
34−import { MirrorReadiness } from "../auth/mirror-readiness";
3534 import { WorkOsMirror } from "../auth/workos-mirror";
3635 import {
3736 CloudAuthPublicHandlers,
@@ -80,9 +79,7 @@ const spec = OpenApi.fromApi(CloudOpenApi); · 42 unmodified lines
8079 * core.
8180 */
8281 export const makeCloudExtensionRoutes = (
83− rsLive: Layer.Layer<
84− DbService | UserStoreService | WorkOsMirror | MemberDirectory | MirrorReadiness
85− >,
82+ rsLive: Layer.Layer<DbService | UserStoreService | WorkOsMirror | MemberDirectory>,
8683 ) => {
8784 // Session routes (login / callback / me / switch-org / …). Handlers yield
8885 // `UserStoreService` directly; the per-request DB combine keeps the postgres
@@ -119,7 +116,9 @@ export const makeCloudExtensionRoutes = ( · 30 unmodified lines
119116 // rather than on the protected API because the protected plane's middleware
120117 // binds a product-view executor to one acting member — this one authorizes an
121118 // org key (or an admin session) and builds a subject-less platform view.
122− const AdminUsersRoutes = makeCloudAdminUsersRoutes(rsLive, { router: apiPrefixedRouter });
119+ const AdminUsersRoutes = makeCloudAdminUsersRoutes(rsLive, {
120+ router: apiPrefixedRouter,
121+ });
123122
124123 // The WorkOS webhook needs no per-request DB layer: it verifies the
125124 // signature with the boot `WorkOSClient` and detaches a reconciler pass
apps/cloud/src/mcp/auth-provider.ts+16−11
@@ -2,7 +2,7 @@ · 1 unmodified line
22 // Cloud McpAuthProvider adapter — the cloud analog of selfHostMcpAuthProviderLayer.
33 //
44 // Folds the entire cloud edge auth/authz surface (WorkOS JWT verify + API-key
5−// bearer + per-request org-liveness check + the two OAuth discovery docs) into
5+// bearer + per-request membership check + the two OAuth discovery docs) into
66 // ONE `McpAuthProvider` Layer behind the shared host-mcp envelope.
77 //
88 // `authenticate(request)` runs on EVERY /mcp request and resolves a typed
@@ -10,13 +10,16 @@ · 1 unmodified line
1010 // - missing bearer -> Unauthorized (challenge: Bearer resource_metadata=…)
1111 // - invalid token/api key -> Unauthorized (challenge: Bearer error="invalid_token" …)
1212 // - transient JWKS OR membership-lookup infra -> Unavailable (caught here;
13−// envelope renders a retryable 503 -32001). A WorkOS blip during the live
14−// org check is a TRANSIENT failure, not evidence the org is gone, so it
15−// must NOT reach the Forbidden/destroy path below.
13+// envelope renders a retryable 503 -32001). The membership check reads
14+// the local mirror (`auth/organization.ts`), so the infra that can fail
15+// here is the database — or WorkOS, on the one path that still asks it
16+// (an organization the mirror has never seen). Either is TRANSIENT, not
17+// evidence the org is gone, and must NOT reach the Forbidden/destroy
18+// path below.
1619 // - no org / revoked org -> Forbidden ("No organization in session …", -32001).
1720 // This requires a POSITIVE determination (the lookup SUCCEEDED and the org
1821 // is absent), never a failed lookup. Because authenticate reads the
19−// mcp-session-id header to do the live org check, the envelope's
22+// mcp-session-id header to do the membership check, the envelope's
2023 // dispose-on-Forbidden-with-sessionId path reproduces the old inline
2124 // clearExistingSession.
2225 // - verified + org allowed -> Authenticated(principal)
@@ -70,15 +73,17 @@ const TOOLKIT_PROTECTED_RESOURCE_METADATA_PATH = `${PROTECTED_RESOURCE_METADATA_ · 47 unmodified lines
7073
7174 const NO_ORGANIZATION_MESSAGE = "No organization in session — log in via the web app first";
7275
73−// A transient WorkOS failure (429 / 5xx / timeout / network) during the live
74−// membership lookup must NOT masquerade as "org revoked" — but the failure
75−// channel alone is not enough to tell them apart: WorkOS also answers with
76−// DEFINITIVE 4xx denials (401 revoked/invalid API key, 403, 404 deleted org)
77−// that the SDK throws as typed exceptions. So the classification is:
76+// A transient failure during the membership lookup must NOT masquerade as
77+// "org revoked". The lookup reads the local mirror, so in steady state its
78+// only failure is the database; the one path that still asks WorkOS (an
79+// organization the mirror has never seen, resolved for a caller WorkOS
80+// confirms as its member) can also fail with a DEFINITIVE 4xx denial (401
81+// revoked/invalid API key, 403, 404 deleted org) that the SDK throws as a
82+// typed exception. So the classification is:
7883 // - lookup SUCCEEDS with `null` -> genuine absence -> Forbidden
7984 // - lookup FAILS with WorkOS 401/403/404 -> definitive denial -> Forbidden
8085 // (fail CLOSED: WorkOS answered and said no; retrying cannot help)
81−// - lookup FAILS any other way (429/5xx/timeout/network/no status)
86+// - lookup FAILS any other way (database, 429/5xx/timeout/network/no status)
8287 // -> transient -> retryable 503, session preserved
8388 // The status rides on `WorkOSError.status` (threaded from the SDK exception at
8489 // the service boundary in auth/workos.ts); `isDefinitiveWorkOSDenial` is the
apps/cloud/src/mcp/auth.ts+6−5
@@ -18,7 +18,6 @@ import { BEARER_PREFIX } from "../auth/bearer"; · 17 unmodified lines
1818 import { authorizeOrganization } from "../auth/organization";
1919 import { UserStoreService, makeUserStoreLayer } from "../auth/context";
2020 import { makeMemberDirectoryLayer } from "../auth/member-directory";
21−import { makeMirrorReadinessLayer } from "../auth/mirror-readiness";
2221 import { makeWorkOsMirrorLayer } from "../auth/workos-mirror";
2322 import { CoreSharedServices } from "../auth/workos";
2423 import { makeDbLayer } from "../db/db";
@@ -211,13 +210,11 @@ const makeMcpOrganizationAuthServices = () => { · 186 unmodified lines
211210 const dbLive = makeDbLayer();
212211 const userStoreLive = makeUserStoreLayer().pipe(Layer.provide(dbLive));
213212 const memberDirectoryLive = makeMemberDirectoryLayer().pipe(Layer.provide(dbLive));
214− const mirrorReadinessLive = makeMirrorReadinessLayer().pipe(Layer.provide(dbLive));
215213 const workOsMirrorLive = makeWorkOsMirrorLayer().pipe(Layer.provide(dbLive));
216214 return Layer.mergeAll(
217215 dbLive,
218216 userStoreLive,
219217 memberDirectoryLive,
220− mirrorReadinessLive,
221218 workOsMirrorLive,
222219 CoreSharedServices,
223220 );
@@ -329,7 +326,9 @@ export const McpAuthLive = Layer.effect( · 105 unmodified lines
329326 if (!verified) return mcpUnauthorized("invalid_token", "The access token is invalid");
330327 if (Predicate.isTagged(verified, "Unauthorized")) return verified;
331328 if (!verified.accountId) {
332− yield* Effect.annotateCurrentSpan({ "mcp.auth.outcome": "missing_subject" });
329+ yield* Effect.annotateCurrentSpan({
330+ "mcp.auth.outcome": "missing_subject",
331+ });
333332 return mcpUnauthorized("invalid_token", "The access token is invalid");
334333 }
335334 yield* Effect.annotateCurrentSpan({
@@ -344,7 +343,9 @@ export const McpAuthLive = Layer.effect( · 8 unmodified lines
344343 verifyBearer: Effect.fn("mcp.auth.verify_bearer")(function* (request) {
345344 const authHeader = request.headers.get("authorization");
346345 if (!authHeader?.startsWith(BEARER_PREFIX)) {
347− yield* Effect.annotateCurrentSpan({ "mcp.auth.outcome": "missing_bearer" });
346+ yield* Effect.annotateCurrentSpan({
347+ "mcp.auth.outcome": "missing_bearer",
348+ });
348349 return mcpUnauthorized("missing_bearer");
349350 }
350351 const token = authHeader.slice(BEARER_PREFIX.length).trim();
apps/cloud/src/org/auth-middleware.ts+8−11
@@ -9,7 +9,6 @@ import { · 8 unmodified lines
99
1010 import { UserStoreService } from "../auth/context";
1111 import { sessionFromSealed } from "../auth/middleware";
12−import { MirrorReadiness } from "../auth/mirror-readiness";
1312 import { WorkOsMirror } from "../auth/workos-mirror";
1413 import { ORG_SELECTOR_HEADER, authorizeOrganizationSelector } from "../auth/organization";
1514 import { WorkOSClient } from "../auth/workos";
@@ -35,19 +34,19 @@ const noOrganization = () => · 19 unmodified lines
3534
3635 /**
3736 * The caller's role in the session org, as `authorizeOrganizationSelector`
38− * read it for THIS request: from the mirror while the mirror is ready, from
39− * WorkOS otherwise (`auth/organization.ts`). Provided beside `AuthContext` —
40− * the shared seam, which carries no role — so the domain handlers' admin gate
41− * is this one value, never a second read of the mirror that would skip the
42− * readiness rule and admit a demoted admin on a stale row while the
43− * reconciler is behind.
37+ * read it for THIS request from the membership mirror (`auth/organization.ts`).
38+ * Provided beside `AuthContext` — the shared seam, which carries no role — so
39+ * the domain handlers' admin gate is this one value, never a second read of
40+ * the mirror.
4441 */
4542 export class OrgMemberRole extends Context.Service<
4643 OrgMemberRole,
4744 { readonly memberRole: "admin" | "member" }
4845 >()("@executor-js/cloud/OrgMemberRole") {}
4946
50−const OrgAuthMiddleware = HttpRouter.middleware<{ provides: AuthContext | OrgMemberRole }>()(
47+const OrgAuthMiddleware = HttpRouter.middleware<{
48+ provides: AuthContext | OrgMemberRole;
49+}>()(
5150 Effect.gen(function* () {
5251 const captured = yield* Effect.context<WorkOSClient>();
5352 const workos = yield* WorkOSClient;
@@ -93,7 +92,5 @@ const OrgAuthMiddleware = HttpRouter.middleware<{ provides: AuthContext | OrgMem · 39 unmodified lines
9392 );
9493
9594 export const orgAuthMiddleware = (
96− rsLive: Layer.Layer<
97− DbService | UserStoreService | MemberDirectory | MirrorReadiness | WorkOsMirror
98− >,
95+ rsLive: Layer.Layer<DbService | UserStoreService | MemberDirectory | WorkOsMirror>,
9996 ) => OrgAuthMiddleware.combine(requestScopedMiddleware(rsLive)).layer;
apps/cloud/src/org/handlers.test.ts+48−64
@@ -5,7 +5,6 @@ import { HttpApiBuilder } from "effect/unstable/httpapi"; · 4 unmodified lines
55
66 import { AuthContext, MemberDirectory, type DirectoryMember } from "@executor-js/api/server";
77 import { UserStoreService } from "../auth/context";
8−import { MirrorReadiness, MirrorReadinessState } from "../auth/mirror-readiness";
98 import { ORG_SELECTOR_HEADER } from "../auth/organization";
109 import { WorkOSClient, type WorkOSClientService } from "../auth/workos";
1110 import { WorkOsMirror, type WorkOsMirrorShape } from "../auth/workos-mirror";
@@ -23,8 +22,8 @@ import { OrgHandlers, assertDomainInSessionOrg, requireAdmin } from "./handlers" · 11 unmodified lines
2322 // share — the REAL `requireAdmin` and `assertDomainInSessionOrg` exported from
2423 // `org/handlers.ts`, so a change to the gate cannot pass on a stale copy — and
2524 // the admin gate's SOURCE: the role `orgAuthMiddleware` resolved for the
26−// request, so a stale mirror row cannot admit a demoted admin while the mirror
27−// is not trusted (the readiness rule in `auth/organization.ts`).
25+// request, read from the local membership mirror unconditionally
26+// (`auth/organization.ts`).
2827 // ---------------------------------------------------------------------------
2928
3029 // eslint-disable-next-line @typescript-eslint/no-explicit-any -- test stub needs wide function types
@@ -99,7 +98,11 @@ describe("Org domain handlers", () => { · 68 unmodified lines
9998 Effect.provide(
10099 provide("admin", {
101100 getOrganizationDomain: () =>
102− Effect.succeed({ id: "dom_1", organizationId: "org_1", domain: "acme.test" }),
101+ Effect.succeed({
102+ id: "dom_1",
103+ organizationId: "org_1",
104+ domain: "acme.test",
105+ }),
103106 }),
104107 ),
105108 ),
@@ -113,7 +116,11 @@ describe("Org domain handlers", () => { · 7 unmodified lines
113116 Effect.provide(
114117 provide("admin", {
115118 getOrganizationDomain: () =>
116− Effect.succeed({ id: "dom_other", organizationId: "org_2", domain: "evil.test" }),
119+ Effect.succeed({
120+ id: "dom_other",
121+ organizationId: "org_2",
122+ domain: "evil.test",
123+ }),
117124 }),
118125 ),
119126 ),
@@ -136,11 +143,12 @@ describe("Org domain handlers", () => { · 16 unmodified lines
136143
137144 // ---------------------------------------------------------------------------
138145 // The admin gate over HTTP, through `orgAuthMiddleware`: the role the gate
139−// sees is the one the middleware resolved through `authorizeOrganizationSelector`
140−// — the mirror while it is ready, WorkOS otherwise. The mirror row below is
141−// STALE: it still says `admin` for a caller WorkOS has demoted to `member`.
142−// While the mirror is not trusted (the reconciler is behind), WorkOS's answer
143−// must decide, and the delete must be refused.
146+// sees is the one the middleware resolved through `authorizeOrganizationSelector`,
147+// which reads the local membership mirror unconditionally — there is no
148+// readiness gate and no WorkOS fallback (`auth/organization.ts`). The mirror
149+// row below is the sole source of the role, so `stubWorkOS` here serves only
150+// session authentication and dies on anything else, proving membership is
151+// never re-checked against WorkOS.
144152 // ---------------------------------------------------------------------------
145153
146154 const ORG = "org_1";
@@ -148,33 +156,30 @@ const CALLER = "user_caller"; · 1 unmodified line
148156 const DOMAIN = "dom_1";
149157 const createdAt = new Date("2026-01-01T00:00:00.000Z");
150158
151−// The mirror's row for the caller: an active admin — stale once WorkOS has
152−// demoted them and the reconciler has not landed the change yet.
153−const staleAdminRow: DirectoryMember = {
159+// The mirror's row for the caller, at whatever role a test sets.
160+const callerRow = (role: "admin" | "member"): DirectoryMember => ({
154161 accountId: CALLER,
155162 membershipId: `om_${CALLER}_${ORG}`,
156163 organizationId: ORG,
157164 email: null,
158165 name: null,
159166 avatarUrl: null,
160− role: "admin",
167+ role,
161168 status: "active",
162169 lastActiveAt: null,
163−};
164−
165−const unread = (why: string) => () => Effect.die(why);
166−const stubDirectory = Layer.succeed(MemberDirectory)({
167− membership: (accountId, organizationId) =>
168− Effect.succeed(accountId === CALLER && organizationId === ORG ? staleAdminRow : null),
169− membershipById: unread("the org plane does not look up by membership id"),
170− membershipsOf: unread("the org plane reads one membership, not the list"),
171− members: unread("the org plane does not list members"),
172− membersById: unread("the org plane does not batch members"),
173− findByEmail: unread("the org plane does not resolve emails"),
174170 });
175171
176−const readiness = (state: MirrorReadinessState) =>
177− Layer.succeed(MirrorReadiness)({ state: () => Effect.succeed(state) });
172+const unread = (why: string) => () => Effect.die(why);
173+const stubDirectory = (role: "admin" | "member") =>
174+ Layer.succeed(MemberDirectory)({
175+ membership: (accountId, organizationId) =>
176+ Effect.succeed(accountId === CALLER && organizationId === ORG ? callerRow(role) : null),
177+ membershipById: unread("the org plane does not look up by membership id"),
178+ membershipsOf: unread("the org plane reads one membership, not the list"),
179+ members: unread("the org plane does not list members"),
180+ membersById: unread("the org plane does not batch members"),
181+ findByEmail: unread("the org plane does not resolve emails"),
182+ });
178183
179184 const organizationRow = (id: string) => ({
180185 id,
@@ -226,22 +231,16 @@ const stubAutumn = Layer.succeed(AutumnService)({ · 45 unmodified lines
226231 setMemberSeats: unread("the delete does not count seats"),
227232 });
228233
229−// WorkOS as the org plane sees it: the caller's session, their CURRENT
230−// membership list (demoted to member), and the domain to delete.
231−const workosWithCallerAs = (role: "admin" | "member", deleted: string[]) =>
234+// WorkOS as the org plane sees it: session authentication and the domain to
235+// delete. `stubWorkOS` dies on anything else, so a `listUserMemberships` call
236+// would fail the test — membership is never re-checked against WorkOS.
237+const workosForCaller = (deleted: string[]) =>
232238 stubWorkOS({
233239 authenticateSealedSession: () =>
234− Effect.succeed({ userId: CALLER, email: "caller@placeholder.test", organizationId: ORG }),
235− listUserMemberships: () =>
236240 Effect.succeed({
237− data: [
238− {
239− id: staleAdminRow.membershipId,
240− organizationId: ORG,
241− status: "active",
242− role: { slug: role },
243− },
244− ],
241+ userId: CALLER,
242+ email: "caller@placeholder.test",
243+ organizationId: ORG,
245244 }),
246245 getOrganizationDomain: () =>
247246 Effect.succeed({ id: DOMAIN, organizationId: ORG, domain: "acme.test" }),
@@ -251,8 +250,8 @@ const workosWithCallerAs = (role: "admin" | "member", deleted: string[]) => · 3 unmodified lines
251250 }),
252251 });
253252
254−const orgApp = (state: MirrorReadinessState, workos: Layer.Layer<WorkOSClient>) => {
255− const rsLive = Layer.mergeAll(stubDb, stubUsers, stubDirectory, stubMirror, readiness(state));
253+const orgApp = (role: "admin" | "member", workos: Layer.Layer<WorkOSClient>) => {
254+ const rsLive = Layer.mergeAll(stubDb, stubUsers, stubDirectory(role), stubMirror);
256255 const App = HttpApiBuilder.layer(OrgHttpApi).pipe(
257256 Layer.provide(OrgHandlers),
258257 Layer.provide(orgAuthMiddleware(rsLive)),
@@ -268,9 +267,9 @@ afterAll(async () => { · 9 unmodified lines
268267 await Promise.all(apps.map((app) => app.dispose()));
269268 });
270269
271−const deleteDomain = async (state: MirrorReadinessState, role: "admin" | "member") => {
270+const deleteDomain = async (role: "admin" | "member") => {
272271 const deleted: string[] = [];
273− const app = orgApp(state, workosWithCallerAs(role, deleted));
272+ const app = orgApp(role, workosForCaller(deleted));
274273 apps.push(app);
275274 const response = await app.handler(
276275 new Request(`https://executor.test/org/domains/${DOMAIN}`, {
@@ -285,30 +284,15 @@ const deleteDomain = async (state: MirrorReadinessState, role: "admin" | "member · 8 unmodified lines
285284 };
286285
287286 describe("Org domain handlers over HTTP: the admin gate is the authorized role", () => {
288− it("lets a mirrored admin delete a domain while the mirror is ready", async () => {
289− const { status, deleted } = await deleteDomain(MirrorReadinessState.Ready(), "member");
290− // WorkOS is not consulted for membership while the mirror is ready: the
291− // mirror row (admin) decides, and the demotion lands through the
292− // reconciler within its lag budget.
287+ it("lets a mirrored admin delete a domain", async () => {
288+ const { status, deleted } = await deleteDomain("admin");
293289 expect(status).toBe(200);
294290 expect(deleted).toEqual([DOMAIN]);
295291 });
296292
297− it("refuses a demoted admin while the mirror is not ready, however stale the mirror row is", async () => {
298− const { status, deleted } = await deleteDomain(
299− MirrorReadinessState.ReconcilerStale({ drainedAt: null }),
300− "member",
301− );
302− expect(status, "WorkOS says member; the stale admin row does not grant the delete").toBe(403);
293+ it("refuses a mirrored plain member, without ever consulting WorkOS", async () => {
294+ const { status, deleted } = await deleteDomain("member");
295+ expect(status).toBe(403);
303296 expect(deleted).toEqual([]);
304297 });
305−
306− it("lets an admin WorkOS confirms delete a domain while the mirror is not ready", async () => {
307− const { status, deleted } = await deleteDomain(
308− MirrorReadinessState.ReconcilerStale({ drainedAt: null }),
309− "admin",
310− );
311− expect(status).toBe(200);
312− expect(deleted).toEqual([DOMAIN]);
313− });
314298 });
e2e/cloud/mcp-workos-blip-session-survival.test.ts+136−105
@@ -1,25 +1,25 @@
1−// Cloud: how the per-request live-membership check classifies WorkOS failures,
2−// pinned in BOTH directions at the real upstream (faults armed on the WorkOS
3−// emulator's membership endpoint — the same emulator the product's real WorkOS
4−// SDK talks to; no product code or stubs touched):
1+// Cloud: an MCP session's relationship to WorkOS after the membership mirror.
52 //
6−// 1. A TRANSIENT WorkOS outage (5xx/timeout) must NOT destroy a live MCP
7−// session. This is the churn-risk defect: a WorkOS blip used to collapse to
8−// Forbidden, and a Forbidden carrying a session id schedules the session
9−// Durable Object for destruction (in-flight executions, paused approvals,
10−// undelivered results — all gone). For a shared-API-key org a single blip
11−// could mass-condemn every session at once. Contract: the blip request fails
12−// RETRYABLY (503 + Retry-After), and once WorkOS recovers the SAME session
13−// id keeps serving requests.
3+// Membership is authorized from the local mirror on every /mcp request
4+// (`auth/organization.ts`); WorkOS is a write target and an event source, not
5+// a per-request read. Two contracts follow, pinned here at the real upstream
6+// (faults armed on the WorkOS emulator's membership endpoint — the same
7+// emulator the product's real WorkOS SDK talks to; no product code or stubs
8+// touched):
149 //
15−// 2. A DEFINITIVE WorkOS denial (401 — the revoked/invalid API key answer) must
16−// fail CLOSED: Forbidden, session condemned. Retrying cannot help; treating
17−// it as transient would preserve sessions indefinitely for a revoked
18−// customer (the fail-open inversion the adversarial review caught).
10+// 1. A WorkOS OUTAGE is INVISIBLE to a live session. Before the mirror, a
11+// 5xx from the membership lookup had to be classified as transient (a
12+// retryable 503 that left the session alive) so a blip could not
13+// mass-condemn every session of a shared-API-key org. Now the request never
14+// asks WorkOS at all: a request issued during the outage is a plain 200,
15+// and the SAME session id keeps serving afterwards. The fault is armed on
16+// the exact endpoint the old check hit, so an unnoticed regression back to
17+// a per-request WorkOS read would fail this as a 503 (or worse, a 403).
1918 //
20−// Red/green for (1): pre-fix, the outage request returns a session-destroying
21−// Forbidden and the post-outage request gets 404 "reconnect". With the fix the
22−// outage request is a 503 and the post-outage request is a clean 200.
19+// 2. A REVOKED membership still fails CLOSED. The mirror is not a cache with a
20+// TTL: a removal made through the product writes the mirror in the same
21+// request, so the removed member's next /mcp request is a Forbidden, the
22+// session is condemned, and the id is dead. Retrying cannot help.
2323 import { expect } from "@effect/vitest";
2424 import { Effect } from "effect";
2525
@@ -29,6 +29,7 @@ import { scenario } from "../src/scenario"; · 3 unmodified lines
2929 import { Mcp, Target } from "../src/services";
3030 import type { Identity } from "../src/target";
3131 import { WORKOS_EMULATOR_PORT } from "../targets/cloud";
32+import { cookieOf, joinOrg, orgSelectorOf } from "./support/session";
3233
3334 const JSON_AND_SSE = "application/json, text/event-stream";
3435 const PROTOCOL_VERSION = "2025-03-26";
@@ -105,11 +106,11 @@ const openSession = async (mcpUrl: string, bearer: string): Promise<string> => { · 70 unmodified lines
105106 return sessionId;
106107 };
107108
108−// The live membership check is `GET /user_management/organization_memberships`
109−// (WorkOS `listOrganizationMemberships`). A bounded count covers the outage
110−// request without leaking into later (post-clear) requests; we also clear
111−// explicitly. `times` is generous so any internal retry inside the one faulted
112−// request still sees the outage, but the finalizer removes whatever remains.
109+// The endpoint the pre-mirror per-request check hit
110+// (`GET /user_management/organization_memberships`). Armed to prove it is no
111+// longer on the request path: a request that reached it would fail. `times`
112+// is generous so any retry inside a faulted request still sees the outage; the
113+// finalizer removes whatever remains.
113114 const MEMBERSHIP_FAULT = {
114115 match: {
115116 method: "GET",
@@ -119,22 +120,8 @@ const MEMBERSHIP_FAULT = { · 3 unmodified lines
119120 times: 8,
120121 } as const;
121122
122−// The definitive-denial counterpart: WorkOS ANSWERS the membership lookup with
123−// 401 — the shape of a revoked/invalid API key. Not a blip; must fail closed.
124−const MEMBERSHIP_DENIAL_FAULT = {
125− match: {
126− method: "GET",
127− pathPattern: "/user_management/organization_memberships*",
128− },
129− response: {
130− status: 401,
131− body: { message: "Could not authorize the request. Maybe your API key is invalid?" },
132− },
133− times: 8,
134−} as const;
135−
136123 scenario(
137− "MCP sessions · a transient WorkOS outage 503s retryably and leaves the session alive",
124+ "MCP sessions · a WorkOS outage is invisible to a live session, which is authorized from the mirror",
138125 {},
139126 Effect.gen(function* () {
140127 const target = yield* Target;
@@ -151,106 +138,150 @@ scenario( · 10 unmodified lines
151138 const healthy = yield* Effect.promise(() =>
152139 mcpPost(target.mcpUrl, { bearer, sessionId, body: toolsList(2) }),
153140 );
154− expect(healthy.status, "the session serves requests before the blip").toBe(200);
141+ expect(healthy.status, "the session serves requests before the outage").toBe(200);
155142 yield* Effect.promise(() => healthy.text());
156143
157144 yield* Effect.gen(function* () {
158− // The blip: WorkOS membership lookups start failing with 503.
145+ // The outage: WorkOS membership lookups would fail with 503 — if
146+ // anything asked.
159147 yield* Effect.promise(() => workos.faults.arm(MEMBERSHIP_FAULT));
160148
161− // A request issued DURING the outage. The membership lookup fails
162− // transiently — this must be a retryable 503, NOT a Forbidden (which
163− // would condemn the session).
149+ // A request issued DURING the outage. Membership is read from the
150+ // mirror, so WorkOS is never consulted and the request is a plain
151+ // success — not a retryable 503 (the pre-mirror contract) and never a
152+ // Forbidden (which would condemn the session).
164153 const duringOutage = yield* Effect.promise(() =>
165154 mcpPost(target.mcpUrl, { bearer, sessionId, body: toolsList(3) }),
166155 );
167− const outageBody = (yield* Effect.promise(() => duringOutage.json())) as JsonRpcError;
168156 expect(
169157 duringOutage.status,
170− "a WorkOS blip is a retryable 503, not a session-destroying error",
171− ).toBe(503);
172− expect(
173− duringOutage.status,
174− "the blip is NOT surfaced as a 404 reconnect (which would mean the session was destroyed)",
175− ).not.toBe(404);
176− expect(
177− outageBody.error.code,
178− "the 503 is a JSON-RPC error envelope the transport retries",
179− ).toBe(-32001);
180− expect(
181− duringOutage.headers.get("retry-after"),
182− "the 503 advertises a Retry-After so clients back off",
183− ).toEqual(expect.any(String));
158+ "a WorkOS outage does not touch a request: membership comes from the mirror",
159+ ).toBe(200);
160+ yield* Effect.promise(() => duringOutage.text());
184161 }).pipe(
185− // Always lift the outage, even if an assertion above fails, so the
186− // recovery request runs against a healthy WorkOS.
162+ // Always lift the outage, even if an assertion above fails.
187163 Effect.ensuring(Effect.promise(() => workos.faults.clear())),
188164 );
189165
190− // WorkOS has recovered. The SAME session id must still serve requests: the
191− // blip left it untouched. On the pre-fix code this is a 404 (the outage
192− // request destroyed the DO); with the fix it is a clean 200.
166+ // The SAME session id keeps serving after the outage: nothing condemned it.
193167 const afterOutage = yield* Effect.promise(() =>
194168 mcpPost(target.mcpUrl, { bearer, sessionId, body: toolsList(4) }),
195169 );
196− expect(
197− afterOutage.status,
198− "the session survived the blip and resumes work once WorkOS recovers",
199− ).toBe(200);
170+ expect(afterOutage.status, "the session is untouched by the outage").toBe(200);
200171 yield* Effect.promise(() => afterOutage.text());
201172 }),
202173 );
203174
204175 scenario(
205− "MCP sessions · a definitive WorkOS denial fails closed and condemns the session",
176+ "MCP sessions · a revoked membership fails closed on the next request and condemns the session",
206177 {},
207178 Effect.gen(function* () {
208179 const target = yield* Target;
209180 const mcp = yield* Mcp;
210− const identity = yield* target.newIdentity();
211− const bearer = yield* mcp.mintBearer(emailOf(identity));
212181
213− const workos = yield* Effect.promise(() =>
214− connectEmulator({ baseUrl: `http://127.0.0.1:${WORKOS_EMULATOR_PORT}` }),
215− );
182+ // An admin's org with one plain member, joined through the real invite →
183+ // accept flow. The member is the one whose access is revoked.
184+ const admin = yield* target.newIdentity();
185+ const invitee = yield* target.newIdentity({ org: false });
186+ const member = yield* joinOrg(target, admin, invitee);
187+ const bearer = yield* mcp.mintBearer(emailOf(member));
188+ const orgSelector = orgSelectorOf(member);
216189
217− // A healthy session doing real work before the denial.
218− const sessionId = yield* Effect.promise(() => openSession(target.mcpUrl, bearer));
219− const healthy = yield* Effect.promise(() =>
220− mcpPost(target.mcpUrl, { bearer, sessionId, body: toolsList(2) }),
221− );
222− expect(healthy.status, "the session serves requests before the denial").toBe(200);
190+ // The member's healthy session doing real work before the revocation.
191+ const mcpUrl = `${target.mcpUrl}`;
192+ const withOrg = (body: unknown, sessionId?: string) =>
193+ fetch(mcpUrl, {
194+ method: "POST",
195+ headers: {
196+ accept: JSON_AND_SSE,
197+ "content-type": "application/json",
198+ authorization: `Bearer ${bearer}`,
199+ "x-executor-mcp-organization": orgSelector,
200+ ...(sessionId ? { "mcp-session-id": sessionId } : {}),
201+ },
202+ body: JSON.stringify(body),
203+ });
204+ const initialize = yield* Effect.promise(() => withOrg(INITIALIZE_REQUEST));
205+ const sessionId = initialize.headers.get("mcp-session-id");
206+ yield* Effect.promise(() => initialize.text());
207+ expect(initialize.status, "the member opens a session in the org").toBe(200);
208+ if (!sessionId) throw new Error("initialize returned no session id");
209+ const initialized = yield* Effect.promise(() => withOrg(INITIALIZED_NOTIFICATION, sessionId));
210+ yield* Effect.promise(() => initialized.text());
211+ const healthy = yield* Effect.promise(() => withOrg(toolsList(2), sessionId));
212+ expect(healthy.status, "the session serves requests before the revocation").toBe(200);
223213 yield* Effect.promise(() => healthy.text());
224214
225− yield* Effect.gen(function* () {
226− // WorkOS starts ANSWERING the membership lookup with 401 — the
227− // revoked/invalid API key shape. Deterministic denial, not a blip.
228− yield* Effect.promise(() => workos.faults.arm(MEMBERSHIP_DENIAL_FAULT));
215+ // The admin removes the member through the product. The removal writes
216+ // the mirror in the same request (a deletion tombstone keyed to the
217+ // WorkOS membership id), so no reconciler tick is needed for it to land.
218+ const members = yield* Effect.promise(async () => {
219+ const response = await fetch(new URL("/api/account/members", target.baseUrl), {
220+ headers: { ...(admin.headers ?? {}) },
221+ });
222+ if (!response.ok) throw new Error(`/api/account/members failed (${response.status})`);
223+ return (await response.json()) as {
224+ readonly members: ReadonlyArray<{ readonly id: string; readonly isCurrentUser: boolean }>;
225+ };
226+ });
227+ const removed = members.members.find((row) => !row.isCurrentUser);
228+ if (!removed) throw new Error("the joined member is not listed in the org");
229+ const removal = yield* Effect.promise(() =>
230+ fetch(new URL(`/api/account/members/${removed.id}`, target.baseUrl), {
231+ method: "DELETE",
232+ headers: { ...(admin.headers ?? {}), origin: new URL(target.baseUrl).origin },
233+ }),
234+ );
235+ expect(removal.status, "the admin removes the member").toBe(200);
236+ yield* Effect.promise(() => removal.text());
229237
230− const denied = yield* Effect.promise(() =>
231− mcpPost(target.mcpUrl, { bearer, sessionId, body: toolsList(3) }),
232− );
233− const deniedBody = (yield* Effect.promise(() => denied.json())) as JsonRpcError;
234− expect(
235− denied.status,
236− "a definitive WorkOS denial fails closed as Forbidden, never a retryable 503",
237− ).toBe(403);
238− expect(deniedBody.error.code, "the denial is a JSON-RPC error envelope").toBe(-32001);
239− }).pipe(Effect.ensuring(Effect.promise(() => workos.faults.clear())));
238+ // The removed member's NEXT request on the live session: a positive
239+ // determination from the mirror that they hold no active membership — a
240+ // real Forbidden, which condemns the session.
241+ const denied = yield* Effect.promise(() => withOrg(toolsList(3), sessionId));
242+ const deniedBody = (yield* Effect.promise(() => denied.json())) as JsonRpcError;
243+ expect(denied.status, "a revoked member fails closed as Forbidden on the next request").toBe(
244+ 403,
245+ );
246+ expect(deniedBody.error.code, "the denial is a JSON-RPC error envelope").toBe(-32001);
240247
241− // The Forbidden carried the session id, so the session was condemned: the
242− // id must NOT serve requests once WorkOS recovers. If this returned 200 the
243− // fail-closed contract is broken (a revoked customer kept a live session).
244− const afterDenial = yield* Effect.promise(() =>
245− mcpPost(target.mcpUrl, { bearer, sessionId, body: toolsList(4) }),
248+ // While revoked, every further request is refused at the gate — still a
249+ // Forbidden, never a 200 (a removed member kept a live session) and never
250+ // a retryable 503 (nothing about this is transient).
251+ const stillDenied = yield* Effect.promise(() => withOrg(toolsList(4), sessionId));
252+ expect(stillDenied.status, "a revoked member stays refused, deterministically").toBe(403);
253+ yield* Effect.promise(() => stillDenied.text());
254+
255+ // The Forbidden carried the session id, so the session was condemned. A
256+ // caller the gate admits proves it: the admin, still a member, presents
257+ // the condemned id with their own bearer. Had the id survived, the answer
258+ // would be the ownership Forbidden (-32003: the session belongs to someone
259+ // else); condemned, it is dead and the client is told to reconnect.
260+ const adminBearer = yield* mcp.mintBearer(emailOf(admin));
261+ const condemned = yield* Effect.promise(() =>
262+ fetch(mcpUrl, {
263+ method: "POST",
264+ headers: {
265+ accept: JSON_AND_SSE,
266+ "content-type": "application/json",
267+ authorization: `Bearer ${adminBearer}`,
268+ "x-executor-mcp-organization": orgSelectorOf(admin),
269+ "mcp-session-id": sessionId,
270+ },
271+ body: JSON.stringify(toolsList(5)),
272+ }),
246273 );
247− expect(
248− afterDenial.status,
249− "the condemned session id is dead after a definitive denial (reconnect required)",
250− ).toBe(404);
251− const afterBody = (yield* Effect.promise(() => afterDenial.json())) as JsonRpcError;
252− expect(afterBody.error.message, "the client is told to reconnect").toMatch(
274+ expect(condemned.status, "the condemned session id is dead (reconnect required)").toBe(404);
275+ const condemnedBody = (yield* Effect.promise(() => condemned.json())) as JsonRpcError;
276+ expect(condemnedBody.error.message, "the client is told to reconnect").toMatch(
253277 /timed out|reconnect|not found/i,
254278 );
279+
280+ // The admin's own access is unaffected by removing someone else.
281+ const adminStillIn = yield* Effect.promise(() =>
282+ fetch(new URL("/api/account/me", target.baseUrl), { headers: { cookie: cookieOf(admin) } }),
283+ );
284+ expect(adminStillIn.status, "the admin keeps their access").toBe(200);
285+ yield* Effect.promise(() => adminStillIn.text());
255286 }),
256287 );