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.
| Route | Permission | Durable effects |
|---|---|---|
POST /teams | groups:manage | Kept-name uniqueness; partner / Zendesk brand / lead membership must be in the tenant; team_leads synced; team.created audit. |
PATCH /teams/{id} | groups:manage | Same 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:manage | Sets 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 /partners | settings:manage | partner_type must be bpo, fintech, sponsor_bank, or other; domains trimmed, lower-cased, blanks removed, de-duplicated. |
PATCH /partners/{id} | settings:manage | Same normalization/validation. |
DELETE /partners/{id} | settings:manage | Hard 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:manage | Membership discarded, status inactive, scopes deleted, linked employee discarded; WorkOS deleteOrganizationMembership tolerates an already-deleted record; membership.removed audit. |
POST /invitations/{id}/revoke | invitations:manage | Only pending invitations; WorkOS revokeInvitation is best-effort; status becomes revoked; invitation.revoked audit. |
POST /invitations/import | invitations:manage | Emails 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.