Skip to content

CXP-857 Handle invite account provisioning duplicates and pending state - #23

Merged
luisina-santos merged 5 commits into
mainfrom
luisinasantos/invite-account-provisioning
Sep 3, 2026
Merged

CXP-857 Handle invite account provisioning duplicates and pending state#23
luisina-santos merged 5 commits into
mainfrom
luisinasantos/invite-account-provisioning

Conversation

@luisina-santos

Copy link
Copy Markdown
Contributor

Summary

  • CreateAccount on the invite resource type now returns ActionRequiredResult for a fresh invite instead of SuccessResult, since Segment never assigns a stable UID until the invitee accepts.
  • On a duplicate-invite error, disambiguates by scanning pending invites (client.FindPendingInviteByEmail, unbounded pagination) for a match: found → ActionRequiredResult with the pending invite resource attached; not found → AlreadyExistsResult{} (Segment returns the identical error message whether the email is a pending invite or an existing full member, and exposes no email-filtered lookup for either, so a full member can't have a resource attached without an unjustified full scan of all users).
  • pkg/connector/client/helpers.go: IsAlreadyExistsError matches Segment's confirmed live error text ("was already invited to join workspace").
  • Delete no longer has a not-found branch — confirmed against the live API that DELETE /invites is itself idempotent and always returns success.
  • cmd/test-server/handlers.go: mock now returns the real confirmed error shape (400, type: "bad-request") for duplicate invites so CI actually exercises these paths.

Test plan

  • go build ./..., go vet ./..., go test ./...
  • Manually verified against the mock server: fresh invite → ActionRequired; duplicate pending invite → ActionRequired with resource attached (via GET /invites scan); duplicate existing member → AlreadyExists with no resource; delete → success.
  • Duplicate-invite error shape (400, bad-request, "was already invited to join workspace") confirmed against the live Segment API for both the pending-invite and already-a-member cases.
  • Delete idempotency confirmed live by the requester; not independently re-verified in this PR.

CreateAccount now returns ActionRequiredResult for fresh invites (Segment
never assigns a UID until the invite is accepted) and disambiguates
duplicate-invite errors by scanning pending invites for a match, since
Segment returns the identical error for "already invited" and "already a
member" and exposes no email-filtered lookup for either. Confirmed against
the live API that DELETE /invites is itself idempotent.
Comment thread pkg/connector/invites.go Outdated
Comment thread pkg/connector/client/client.go
Comment thread pkg/connector/client/client.go Outdated
Comment thread cmd/test-server/handlers.go
Comment thread pkg/connector/client/helpers.go
Comment thread cmd/test-server/handlers.go
@github-actions

github-actions Bot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Connector PR Review: CXP-857 Handle invite account provisioning duplicates and pending state

Blocking Issues: 0 | Suggestions: 3 | Threads Resolved: 0
Criteria: Criteria status: loaded .claude/skills/ci-review.md from trusted base 0ffc1d7b02d3.
Review mode: full
View review run

Review Summary

Scanned the full PR diff (5 files, +153/-18) for security and correctness: the invite CreateAccount duplicate-disambiguation flow, the new unbounded FindPendingInviteByEmail client scan, the IsAlreadyExistsError message match, the hasRateLimitData annotation guard, and the mock-server duplicate/casing handling. The prior finding on pkg/connector/helpers.go:394 is addressed — commit 2487f5a added the rl.HasResetAt() presence check, so the zero-valued RateLimitDescription that doRequest returns on transport failure now correctly reports false instead of being rescued by AsTime() returning the Unix epoch. No blocking issues found; the three items below are robustness and test-coverage suggestions.

Security Issues

None found.

Correctness Issues

None found.

Suggestions

  • pkg/connector/client/client.go:327-344FindPendingInviteByEmail scans every page with no page cap and no cursor-progress check; a non-advancing pagination.next would spin indefinitely inside a single CreateAccount call.
  • pkg/connector/invites.go:143 — the pending-invite scan failure is logged at Debug and then discarded, so a 401/5xx/transport error becomes an invisible non-error. Log at Warn with the error attached.
  • pkg/connector/client/helpers.go:7 — no unit tests for the new pure helpers IsAlreadyExistsError and hasRateLimitData, despite hasRateLimitData shipping a logic bug in this branch that a table test would have caught.
Prompt for AI agents
Verify each finding against the current code and only fix it if needed.

## Suggestions

In `pkg/connector/client/client.go`:
- Around lines 327-344: The unbounded for loop in FindPendingInviteByEmail terminates only
  when response.Data.Pagination.Next is empty. Add two guards: a maximum page count
  (for example 100 iterations) and a check that the newly returned cursor differs from the
  cursor just used. If either guard trips, stop scanning and return
  ("", false, lastRateLimit, nil) so the caller falls through to the AlreadyExists branch
  instead of looping forever issuing GET /invites requests inside a single CreateAccount
  call.

In `pkg/connector/invites.go`:
- Around line 143: l.Debug("failed to scan pending invites for duplicate lookup", ...)
  logs a genuine upstream API failure (401, 5xx, transport error) at Debug level and then
  returns a nil error, so nothing surfaces at the default log level and the SDK cannot
  retry. Change the call to l.Warn(...), keeping zap.String("email", email) and
  zap.Error(lookupErr). Keep the skip-and-continue ActionRequiredResult return as-is.

In `pkg/connector/client/helpers.go`:
- Around line 7: Add a helpers_test.go in package client with a table-driven test for
  IsAlreadyExistsError covering: a nil error, an error whose message contains Segment's
  real text ("type: bad-request, message: One or more email address was already invited to
  join workspace."), a mixed-case variant of that text, and an unrelated error such as a
  500. Also add a table-driven test for hasRateLimitData in package connector covering:
  nil, the zero-valued RateLimitDescription that doRequest returns on transport failure
  (must be false), one with only Status set, one with only Limit greater than zero, and
  one with only ResetAt set.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No blocking issues found.

- Return ActionRequiredResult instead of AlreadyExistsResult when the
  pending-invite lookup itself fails, rather than assuming the duplicate
  is a full member.
- Propagate the pending-invite scan's rate limit info to CreateAccount's
  output annotations.
- Make the mock server's DELETE /invites idempotent, matching the
  confirmed live behavior and the connector's Delete.
- Normalize the mock's invite/user email comparisons to be
  case-insensitive, matching FindPendingInviteByEmail.
@luisina-santos luisina-santos changed the title Handle invite account provisioning duplicates and pending state CXP-857 Handle invite account provisioning duplicates and pending state Sep 2, 2026
@linear-code

linear-code Bot commented Sep 2, 2026

Copy link
Copy Markdown

CXP-857

Comment thread pkg/connector/invites.go Outdated
}

existingEmail, found, scanRateLimit, lookupErr := b.client.FindPendingInviteByEmail(ctx, email)
outputAnnotations.WithRateLimiting(scanRateLimit)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: annotations.Update replaces an existing annotation of the same type, so this call overwrites the CreateInvites rate-limit descriptor set on line 132. That is fine when the scan produced real data, but doRequest returns &ratelimitData unconditionally (a zero-valued struct when the transport call fails before a response), so FindPendingInviteByEmail's if rl != nil guard at client.go:329 cannot filter that out — on the lookupErr path the good create-call descriptor gets replaced with STATUS_UNSPECIFIED / limit 0 / remaining 0. A nil scanRateLimit also clobbers, since a typed-nil *v2.RateLimitDescription is not caught by Update's msg == nil check. Suggest guarding on content, e.g. if scanRateLimit.GetStatus() != v2.RateLimitDescription_STATUS_UNSPECIFIED || scanRateLimit.GetLimit() > 0 { outputAnnotations.WithRateLimiting(scanRateLimit) }. (medium confidence on impact, high on mechanism)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — added a hasRateLimitData guard before attaching the scan's rate limit, so a zero-valued *v2.RateLimitDescription from a failed/short-circuited page (which doRequest returns non-nil even on transport failure) can no longer overwrite the real one from CreateInvites.

Comment thread pkg/connector/invites.go
Comment on lines +140 to +144
if lookupErr != nil {
l.Debug("failed to scan pending invites for duplicate lookup", zap.String("email", email), zap.Error(lookupErr))
return &v2.CreateAccountResponse_ActionRequiredResult{
Message: fmt.Sprintf("Segment reported %s as a duplicate, but the pending-invite lookup used to confirm whether it's already a full member failed. Retry account creation.", email),
}, nil, outputAnnotations, nil

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: the mis-attribution from the previous review is fixed, but lookupErr is still discarded — logged at Debug (below the default level) and returned as ActionRequiredResult with a nil error. A permanent failure such as 401/403 (token missing invite-read scope) or a 5xx becomes a benign "Retry account creation", so retries loop with no operator signal; context.Canceled mid-scan likewise reports action-required instead of cancellation. Suggest logging at Warn per the repo's log-level rules and returning the wrapped error (or at minimum propagating cancellation) rather than swallowing it into a non-error result.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Kept as ActionRequiredResult (not propagating the error) — this was investigated further, not overlooked. Checked the C1 platform repo (ductone/c1): ActionRequiredResult and a resource-less AlreadyExistsResult are handled identically by AccountManagementController (create_account.go) — both park the ticket for a sync-driven resume, or in the automation/FSM path (no ticket to park) become a non-retryable workflow error with the connector's message surfaced directly (workflow_execution_process.go). There's an explicit comment in that file noting a real Go error from CreateAccount was tried for a related case and caused 'retry-until-timeout instead of fail-fast' in that same automation path. So propagating this lookup error as a hard error risks exactly that blind-retry behavior, for no benefit — the parking/non-retryable-failure semantics we get from ActionRequiredResult already surface the failure without it. Log level is intentionally Debug, not Warn — the message itself already reaches the tenant through the platform's action-required surface, so a louder connector-side log isn't adding new visibility.

Comment thread cmd/test-server/handlers.go Outdated
if ts.invites[strings.ToLower(invite.Email)] {
// Verified against the live Segment API.
logf("❌ POST /invites - already invited: %s", invite.Email)
http.Error(w, `{"errors":[{"type":"bad-request","message":"One or more email address was already invited to join workspace."}]}`, http.StatusBadRequest)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: http.Error forces Content-Type: text/plain; charset=utf-8, so this JSON body never reaches uhttp's JSON error path — WithErrorResponse bails at the IsJSONContentType check and returns the "unexpected content type for JSON error response … body: %s" error instead. IsAlreadyExistsError still matches only because the raw body is interpolated into that message, so CI never exercises the ErrorResponse.Message() decode that production depends on. Write the header explicitly (w.Header().Set("Content-Type", "application/json"); w.WriteHeader(http.StatusBadRequest); w.Write(...)) to make the mock match the real error shape the PR describes.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — added a sendJSONError helper that sets Content-Type: application/vnd.segment.v1+json and writes the body directly, instead of http.Error (which forces text/plain). This now exercises the real ErrorResponse.Message() JSON-decode path on the client instead of only matching via the generic 'unexpected content type' fallback message.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No blocking issues found.

Comment thread pkg/connector/invites.go
Comment thread pkg/connector/client/helpers.go Outdated
Comment thread cmd/test-server/handlers.go Outdated
Comment thread pkg/connector/client/client.go
Comment thread pkg/connector/invites.go Outdated
- Guard against a zero-valued rate limit from a failed pending-invite
  scan overwriting the real one from CreateInvites.
- Fix the mock's mismatched Content-Type on duplicate-invite errors so
  it exercises the client's real JSON error decode path.
- Drop the dead codes.AlreadyExists branch (Segment returns 400, not
  409, for this endpoint).
- Preserve original email casing in the mock's invite store so
  CreateAccount and the next List() sync mint the same resource ID for
  a mixed-case email.
- Simplify a redundant presence check on the profile map.

Confirmed against the C1 platform (ductone/c1) that a resource-less
AlreadyExistsResult and ActionRequiredResult are handled identically
by AccountManagementController - both park the ticket for a
sync-driven resume, or fail non-retryably in automation/workflow
paths. A genuine error from CreateAccount was found to cause
retry-until-timeout in that same automation path instead, so the
pending-invite lookup failure keeps returning ActionRequiredResult
rather than propagating the error.
Comment thread cmd/test-server/handlers.go
Comment thread pkg/connector/helpers.go

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No blocking issues found.

- CreateAccount's fresh-invite success path now builds the resource
  from CreateInvites' own echoed email instead of the caller's raw
  input, matching how the duplicate branch already trusts the API's
  returned value over guessing at casing normalization.
- hasRateLimitData now also treats a non-zero ResetAt as real data and
  explicitly guards a nil input, so a legitimate response with only a
  reset header isn't mistaken for the zero-valued struct doRequest
  returns on transport failure.
Comment thread pkg/connector/helpers.go Outdated
if rl.GetStatus() != v2.RateLimitDescription_STATUS_UNSPECIFIED || rl.GetLimit() > 0 {
return true
}
return !rl.GetResetAt().AsTime().IsZero()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 Bug: this new line makes hasRateLimitData return true unconditionally for any non-nil rl, defeating the guard in exactly the case its doc comment names. (*timestamppb.Timestamp)(nil).AsTime() is time.Unix(0,0).UTC() = 1970-01-01, and time.Time.IsZero() is only true for Jan 1 year 1 — so a nil ResetAt yields !IsZero() == true. doRequest returns &ratelimitData (a zero-valued struct with ResetAt == nil) on transport failure, so pkg/connector/invites.go:139 now always overwrites the real CreateInvites rate-limit annotation with the all-zero one from a failed scan.

Note the real-response case works correctly for the opposite reason: ratelimit.ExtractRateLimitData always sets ResetAt: timestamppb.New(resetAt), and a missing reset header gives the Go zero time, which does satisfy IsZero(). So the nil-vs-set distinction is what needs checking:

Suggested change
return !rl.GetResetAt().AsTime().IsZero()
return rl.HasResetAt() && !rl.GetResetAt().AsTime().IsZero()

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed real bug, fixed — added the HasResetAt() guard as suggested. Verified with a standalone check against all four cases (doRequest's fake zero-valued struct, a real response with no reset header, a real response with a reset time, and nil): only the two real-reset-time cases now return true.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking issues found — see review comments.

@mateoHernandez123 mateoHernandez123 left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM!

A nil ResetAt (doRequest's zero-valued struct on transport failure)
converts via AsTime() to the Unix epoch, not Go's zero time, so
!IsZero() was true for it - the opposite of the intended guard. Add
HasResetAt() so the IsZero() check only runs when ResetAt was actually
populated, which is also true for ExtractRateLimitData's real
no-reset-header case (it always sets ResetAt, just to Go's zero time).
Comment on lines +327 to +344
for {
response, rl, err := c.ListInvites(ctx, cursor, DefaultPageSize)
if rl != nil {
lastRateLimit = rl
}
if err != nil {
return "", false, lastRateLimit, fmt.Errorf("find pending invite by email: %w", err)
}
for _, inviteEmail := range response.Data.Invites {
if strings.EqualFold(inviteEmail, email) {
return inviteEmail, true, lastRateLimit, nil
}
}
cursor = response.Data.Pagination.Next
if cursor == "" {
return "", false, lastRateLimit, nil
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: this loop has no upper bound and no cursor-progress check, so it relies entirely on Segment eventually returning an empty pagination.next. If the API ever returns a cursor that doesn't advance (a stale or repeated cursor), this spins forever issuing GET /invites inside a single CreateAccount call, only stopping when the caller's context deadline fires. Consider capping the scan at N pages and bailing if the new cursor equals the previous one, returning found=false in that case.

Comment thread pkg/connector/invites.go
outputAnnotations.WithRateLimiting(scanRateLimit)
}
if lookupErr != nil {
l.Debug("failed to scan pending invites for duplicate lookup", zap.String("email", email), zap.Error(lookupErr))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: lookupErr here can be a real upstream failure (401, 5xx, transport error) but it's logged at Debug and then discarded — the SDK sees a nil error, so it won't retry, and at the default log level an operator gets the "retry account creation" message with no trace of the cause. Skip-and-continue is the right call, but log it at Warn with zap.Error(lookupErr) so the underlying failure is visible.


const alreadyInvitedMessage = "was already invited to join workspace"

func IsAlreadyExistsError(err error) bool {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: IsAlreadyExistsError and hasRateLimitData are both pure, dependency-free functions that gate the entire duplicate-handling path, and neither gets a unit test in this PR. hasRateLimitData already shipped a logic bug in this branch (the nil-ResetAt case fixed in 2487f5a) that a three-case table test would have caught. A small helpers_test.go covering the real Segment message, a non-matching error, and nil — plus zero/nil/populated RateLimitDescription — would lock in both.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No blocking issues found.

@luisina-santos
luisina-santos merged commit ec1c392 into main Sep 3, 2026
11 checks passed
@luisina-santos
luisina-santos deleted the luisinasantos/invite-account-provisioning branch September 3, 2026 20:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants