@codex review
Closes MCPG-219.
SCIM reconcile aborts at load_directory_snapshot for any tenant whose FusionAuth user dataset fits in a single page. The repeated-nextResults guard inside listAllFusionAuthUsers fires because FA returns a deterministic "reset" cursor on the page past end-of-data, and following that cursor loops pagination back to the start.
Walked /api/user/search directly with the master API key + tenant header. Tenant has 5 users, pageSize=100:
| Page | Users | nextResults (decoded) |
|---|---|---|
| 1 | 5 | { "ls": ["7.58387", null, "edson@…", "<uuid>"], "qs": "*", "sf": [...] } — anchor at last user |
| 2 | 0 | { "qs": "*", "sf": [...] } — no ls field = reset cursor |
| 3 | 5 (the same 5 again) | { "ls": ["7.933935", null, "edson@…", "<uuid>"], "qs": "*", "sf": [...] } — anchor at last user, fresh score |
Cursor 4 would equal cursor 2 (the reset has no time-dependent state), so the seenNextResults guard fires on iteration 4. This matches the prod scim_last_error_summary on planktoncomposio from 2026-05-18:
message=FusionAuth user pagination repeated nextResults; aborting to prevent an infinite loop
Confirmed candidate hypothesis #1 from the ticket. Not encoding (no % chars in any cursor) and not a fencepost.
The page content is authoritative — if FA gave us fewer than pageSize users, we already have everything, regardless of what FA says about cursors. Break before following any cursor:
if (pageUsers.length < pageSize) {
break;
}
if (currentNextResults) {
// existing seen-cursor guard + cursor follow
...
}
The seenNextResults guard stays as a last-resort safety net for the hypothetical case where FA ever loops on full pages.
listAllFusionAuthUsers is the only function in apps/admin/server/fusionauth.ts that follows cursors; the group/group-member listings already exit on pageItems.length < pageSize in their startRow-based loops.follows nextResults cursors when FusionAuth returns cursor-based user paging — old shape (1 user + cursor) is unrealistic, FA wouldn't send a cursor for a partial page. Now uses 100 users + cursor on page 1, 50 users no cursor on page 2.ignores a nextResults cursor returned alongside a partial page (MCPG-219) — locks in the regression. Mocks 5 users + cursor, asserts exactly one upstream request was made.fails safely when FusionAuth repeats a nextResults cursor — bumped to use full pages so the seen-cursor guard remains the path under test (with this fix, a partial-page+cursor would exit before reaching the guard).startRow-based and already handle partial pages correctly.seenNextResults guard — still useful as a last-resort safety net.load_group_memberships, projection, etc.). MCPG-219 unblocks step 1 of reconcile only; full end-to-end reconcile validation needs the MCPG-209 (#509–#512) and MCPG-193 (#516–#518) chains to also land.bunx turbo check-types --filter=@repo/admin — greenbunx turbo lint --filter=@repo/admin — greenbun + vitest + zod-v4 hits the pre-existing z.object is undefined issue that fails on clean main, same as #509–#518)scim_connection_state flips from degraded back to active@codex review
Codex Review: Didn't find any major issues. Keep them coming!
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
@codex review
Codex Review: Didn't find any major issues. Bravo.
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
The SCIM directory-snapshot reconcile step trips our infinite-loop guard at apps/admin/server/fusionauth.ts:996-1003 when paginating users for the planktoncomposio tenant. The reconcile aborts at its first step (load_directory_snapshot), the failure cascade sets organizations.scim_connection_state = 'degraded', and the error message is written to scim_last_error_summary.
Independent of MCPG-193 — different code path, different fix, different blast radius. They share only planktoncomposio as their venue.
From the planktoncomposio org row on 2026-05-18 11:12:39 -0300:
scim_connection_state: degraded
scim_last_error_summary:
step=load_directory_snapshot; runType=manual_resync; fullRun=true;
trpcCode=INTERNAL_SERVER_ERROR;
message=FusionAuth user pagination repeated nextResults; aborting to prevent an infinite loop
The matching scim_directory_connections row has the same connection_state: degraded + identical last_error_summary.
A separate observation from a current probe of /api/group?numberOfResults=5 against the same tenant: FA returned total: null for groups. If the same pattern (missing total) shows up on /api/user/search for this tenant, our pagination code's exit logic may be making unexpected decisions.
The guard fires inside listAllFusionAuthUsers at apps/admin/server/fusionauth.ts:996-1003:
if (seenNextResults.has(currentNextResults)) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message:
"FusionAuth user pagination repeated nextResults; aborting to prevent an infinite loop",
});
}
seenNextResults.add(currentNextResults);
nextResults = currentNextResults;
listAllFusionAuthUsers is called from exactly one place: apps/admin/server/scim/reconcile.ts:370. The function is not in the group-webhook auto-create chain — it's reconcile-only.
nextResults for two consecutive pages instead of advancing. Could be specific to planktoncomposio's user data (a malformed user from the broken converter lambdas — see MCPG-209 Bug 1), specific to the tenant's configuration, or a FusionAuth bug.encodeURIComponent(nextResults). If FA returns a cursor that already contains URL-encoded characters, double-encoding could collapse two distinct cursors to the same seenNextResults key.nextResults token (we start with startRow=0). There may be an edge case where an empty-string or undefined cursor is misclassified as "seen" on the second pass.Add structured observability INSIDE the guard before throwing — log the full seenNextResults array (or at least its size and the conflicting cursor value, truncated) so the next occurrence in prod tells us exactly which root cause is correct. Could ship as a small focused PR with no behavior change.
After that lands, trigger a manual_resync against planktoncomposio, read the new log, pick the fix.
scim_connection_state flips back to active.The latest updates on your projects. Learn more about Vercel for GitHub.
| Project | Deployment | Actions | Updated (UTC) |
|---|---|---|---|
| composio-enterprise-admin | Preview | May 21, 2026 8:13pm | |
| composio-enterprise-internal | May 21, 2026 8:13pm |