Skip to Content
Internal docs are powered by Nextra Docs Theme.
SystemsPlatformAPI2Rails to API2 migrationPackagesOrganization directory Rails-client retirement

Organization directory Rails-client retirement

ENG-3031  removes the last 13 Rails-client runtime importers from the teams, partners, memberships, and invitations feature boundaries in rulebase-ui. The four boundaries now call API2 exclusively. API2 gains the missing native writes under src/routes/org-directory-mutations.ts, with the domain logic in src/lib/org-directory/ (teams, partners, memberships, invitations, audit, workos).

Routes and side-effect map

All routes require a non-agent web session, resolve the Rails organization for the current API2 organization, decode Rails prefix IDs, and reject any record that belongs to another tenant with 404. Audit rows are written to public.audit_events with actor_type = 'user', source = 'web_ui', the caller’s membership, forwarded IP, user agent, and request id, matching Rails’ Auditable concern.

RoutePermissionDurable effects
POST /teamsgroups:manageKept-name uniqueness; partner / Zendesk brand / lead membership must be in the tenant; team_leads synced; team.created audit.
PATCH /teams/{id}groups:manageSame validation; team_leads replaced (team_lead_membership_ids wins over team_lead_membership_id); team.updated audit with from/to scalar changes.
DELETE /teams/{id}groups:manageSets discarded_at; employees’ team_id nulled; employees_count/active_*_count reset; membership_scopes and invitation_scopes rows for the team deleted; team.destroyed audit. Replay on an already-discarded team returns 404 without further writes.
POST /partnerssettings:managepartner_type must be bpo, fintech, sponsor_bank, or other; domains trimmed, lower-cased, blanks removed, de-duplicated.
PATCH /partners/{id}settings:manageSame normalization/validation.
DELETE /partners/{id}settings:manageHard delete; employees’ partner_id nulled; Partner membership/invitation scopes deleted.
PATCH /memberships/{id}memberships:manage (+ settings:manage to grant Administrator unless already held)One transaction: role, scope_type, full_workspace_access (derived from scope_type = 'organization' when omitted), scopes replaced with tenant-validated Team/Partner rows. WorkOS updateOrganizationMembership runs only after commit, with the role’s workos_role_slug; membership.updated audit. WorkOS failure surfaces as 500 after the local change is durable; a retry with the same role completes the WorkOS push without re-applying the change.
DELETE /memberships/{id}memberships:manageMembership discarded, status inactive, scopes deleted, linked employee discarded; WorkOS deleteOrganizationMembership tolerates an already-deleted record; membership.removed audit.
POST /invitations/{id}/revokeinvitations:manageOnly pending invitations; WorkOS revokeInvitation is best-effort; status becomes revoked; invitation.revoked audit.
POST /invitations/importinvitations:manageEmails normalized; duplicate pending invitations and existing members rejected as 422 before any write; invitations and scopes created in one transaction; WorkOS delivery happens after commit and records workos_invitation_id/expires_at per row; invitation.imported audit. If delivery fails the undelivered row is marked revoked (matching Rails’ orphan cleanup) and the request returns 500; re-importing the same email creates a fresh pending invitation.

Rails continues to own the models, callbacks, and the remaining controllers for other callers. No schema migration, feature flag, or queue change ships with this package; rails-schema.ts only declares the pre-existing roles.workos_role_slug and memberships.workos_membership_id columns.

Verification

src/routes/org-directory-mutations.db.test.ts runs the Hono app against a PGlite instance seeded with an anonymized production-shaped tenant plus a second tenant: prefixed IDs across every table, Administrator / QA Lead / Agent roles with WorkOS slugs, partners with types and domains, Zendesk brands, teams with leads, memberships with team/partner scopes, pending and accepted invitations, and employees. The WorkOS SDK is mocked only at the network edge and records call order. Coverage includes positive paths, permission failures, Administrator escalation, malformed and foreign IDs, kept-name collision, partner-type and domain validation, scope validation, DB-failure rollback via an injected trigger, WorkOS-after-commit ordering, WorkOS failure/retry, idempotent replay of discard/revoke, and the full audit metadata contract.

Source census on the branch: rulebase-ui Rails-client importers drop from 266 source/test (255 runtime) to 253 (242 runtime); the four target feature folders have zero runtime imports of ~/lib/rulebase-api-client/client or ~/lib/rulebase-api-client/error, and no Rails base URL. Type-only imports of the shared aliases in ~/lib/rulebase-api-client/types (Membership, Team, Partner, Role, …) remain, because those aliases are shared with the API2-backed read hooks; they are retired with the shared types module, not per feature. Required pre-push checks: pnpm typecheck and pnpm test in rulebase-api2, and pnpm -r lint && pnpm -r typecheck in rulebase-web.

Rollout, reconciliation, and rollback

Deploy API2 in both regions before the UI bundle; the UI has no Rails fallback for these writes. No production mutation, flag change, or replay is part of the rollout. Observation window: 72 hours after the UI deploy, checked at 24 h and 72 h with the read-only query below (run per region as a read-only session).

-- Eligible writes in the window and the invariants each one must leave true. WITH window AS ( SELECT now() - interval '72 hours' AS since ), directory_audits AS ( SELECT action, count(*) AS n FROM public.audit_events, window WHERE occurred_at >= window.since AND action IN ('team.created','team.updated','team.destroyed', 'membership.updated','membership.removed', 'invitation.revoked','invitation.imported') GROUP BY action ), violations AS ( SELECT 'discarded_team_with_scope' AS invariant, count(*) AS n FROM public.teams t WHERE t.discarded_at IS NOT NULL AND ( EXISTS (SELECT 1 FROM public.membership_scopes s WHERE s.scope_type = 'Team' AND s.scope_id = t.id) OR EXISTS (SELECT 1 FROM public.invitation_scopes s WHERE s.scope_type = 'Team' AND s.scope_id = t.id) OR EXISTS (SELECT 1 FROM public.employees e WHERE e.team_id = t.id)) UNION ALL SELECT 'dangling_partner_reference', count(*) FROM ( SELECT partner_id AS pid FROM public.employees WHERE partner_id IS NOT NULL UNION ALL SELECT scope_id FROM public.membership_scopes WHERE scope_type = 'Partner' UNION ALL SELECT scope_id FROM public.invitation_scopes WHERE scope_type = 'Partner' ) r WHERE NOT EXISTS (SELECT 1 FROM public.partners p WHERE p.id = r.pid) UNION ALL SELECT 'kept_team_name_collision', count(*) FROM ( SELECT organization_id, name FROM public.teams WHERE discarded_at IS NULL GROUP BY 1, 2 HAVING count(*) > 1) d UNION ALL SELECT 'full_workspace_access_mismatch', count(*) FROM public.memberships WHERE discarded_at IS NULL AND full_workspace_access IS DISTINCT FROM (scope_type = 'organization') UNION ALL SELECT 'cross_tenant_scope', count(*) FROM public.membership_scopes s JOIN public.memberships m ON m.id = s.membership_id LEFT JOIN public.teams t ON s.scope_type = 'Team' AND t.id = s.scope_id LEFT JOIN public.partners p ON s.scope_type = 'Partner' AND p.id = s.scope_id WHERE COALESCE(t.organization_id, p.organization_id) IS DISTINCT FROM m.organization_id UNION ALL SELECT 'discarded_membership_with_scope_or_employee', count(*) FROM public.memberships m WHERE m.discarded_at IS NOT NULL AND ( EXISTS (SELECT 1 FROM public.membership_scopes s WHERE s.membership_id = m.id) OR EXISTS (SELECT 1 FROM public.employees e WHERE e.membership_id = m.id AND e.discarded_at IS NULL)) UNION ALL SELECT 'revoked_invitation_without_audit', count(*) FROM public.invitations i, window WHERE i.status = 'revoked' AND i.updated_at >= window.since AND NOT EXISTS (SELECT 1 FROM public.audit_events a WHERE a.action = 'invitation.revoked' AND a.target_id = i.id) UNION ALL SELECT 'pending_invitation_without_workos_id', count(*) FROM public.invitations i, window WHERE i.status = 'pending' AND i.created_at >= window.since AND i.workos_invitation_id IS NULL AND i.created_at < now() - interval '15 minutes' UNION ALL SELECT 'pending_invitation_without_import_audit', count(*) FROM public.invitations i, window WHERE i.status = 'pending' AND i.created_at >= window.since AND NOT EXISTS (SELECT 1 FROM public.audit_events a WHERE a.action = 'invitation.imported' AND a.organization_id = i.organization_id AND a.occurred_at BETWEEN i.created_at - interval '1 minute' AND i.created_at + interval '5 minutes') ) SELECT 'audit' AS kind, action AS name, n FROM directory_audits UNION ALL SELECT 'violation', invariant, n FROM violations ORDER BY 1, 2;

Expected result: every violation row is 0; the audit rows are non-zero for the actions the UI exercised, and their totals must be no lower than the same window’s totals on the day before the UI deploy (Rails and API2 write the same public.audit_events table, so a drop indicates writes that stopped auditing). Also confirm API2 5xx for these routes stays at zero in Sentry and that WorkOS membership role/deletion counts follow the membership.updated / membership.removed totals.

Rollback trigger: any non-zero violation count that traces to a web_ui/API2 request, any sustained 5xx on these routes, or a WorkOS role or invitation mismatch reported by the existing WorkOS membership reconciliation. Rollback restores the previous UI bundle first (it still contains the Rails client for these flows); API2 can stay deployed because the routes are additive. Rows written by API2 are ordinary Rails-shaped records and must not be deleted; a pending_invitation_without_workos_id row should be revoked in the UI and the import repeated for that email.

Last updated on