Skip to Content
Internal docs are powered by Nextra Docs Theme.
SystemsPlatformAPI2Rails to API2 migrationPackagesGateway prefix retirement

Gateway prefix retirement

The legacy Rails gateway (rulebase-web/rulebase-api2/src/lib/legacy-rails-gateway/) forwards a request to Rails only when API2 has no method-specific handler for it and its first path segment is in rails-public-route-prefixes.json. Prefixes in retired-rails-route-prefixes.json are answered locally with Rails’ JSON 404 and outcome=retired. Retiring a prefix is how “Rails is idle” becomes “Rails receives no public traffic” for that route family; the separate audit of API2’s direct RAILS_INTERNAL_BASE_URL callers is what turns that into “Rails is unused”.

The two JSON files are kept consistent with the Rails route table by rulebase-api/spec/lint/api2_gateway_compatibility_prefixes_spec.rb: allowlist = Rails route prefixes − blocked (/internal, /good_job, …) − retired, and every retired prefix must still be a Rails route.

Retirement procedure

Run this once per prefix (or per batch). Every step is mechanical; the only judgement is step 2.

This is step 4 of the Workstream 1 execution sequence; /knowledge_base_documents is the worked precedent. Before starting a batch, read the plan, refresh the boundary measurements, reconcile merged revisions, and add a ledger row for the batch when the PR is up.

  1. Measure a clean window. Find the last gateway-behaviour deploy: git log -3 --format='%h %ci %s' -- rulebase-web/rulebase-api2/src/lib/legacy-rails-gateway flightcontrol.cue, then take the time that deploy finished in Flightcontrol as the window start. Query legacy_rails_gateway events with outcome=forwarded after that point in both regions (/ecs/fc-rulebase-api2-prod-5whj12suTaskDefinition, us-east-1; /ecs/fc-rulebase-api2-prod-eu-9rfp06kTaskDefinition, eu-central-1), grouped by first path segment, method, path, status. Never use a window that straddles a deploy that changed forwarding behaviour: counts from before the cutover are not evidence that a prefix is live.

    What the gateway logs do and do not prove. Zero forwarded events for a prefix means no inbound public traffic reached Rails through the gateway for it. It does not mean Rails no longer serves the prefix: about twenty API2 modules call the Rails origin directly with a plain fetch against env.RAILS_INTERNAL_BASE_URL (for example lib/qa-sampling-recovery/rails.ts, lib/sales-call-compliance/rails.ts, lib/run-scheduled-export.ts, lib/scorecard-chat-context.ts hitting /scorecards/:id, routes/slack-interactions.ts), bypassing createLegacyRailsGateway and emitting no legacy_rails_gateway event. Retiring a prefix only changes the gateway’s handling of inbound public requests, so those internal calls keep working — but a “Rails is unused” claim needs all three legs:

    • gateway legacy_rails_gateway events (inbound public traffic);
    • Rails’ own request logs (/ecs/fc-rulebase-api-prod-xv109mmTaskDefinition, us-east-1; /ecs/fc-rulebase-api-prod-eu-u56f166nTaskDefinition, eu-central-1), filtered ispresent(path) and path != "/up", which see every request from any source;
    • rg RAILS_INTERNAL_BASE_URL rulebase-web/rulebase-api2/src for direct API2 callers of the prefix.

    The third leg is the one that is easy to forget.

  2. Classify the prefix. For each Rails route under it (bin/rails routes -g '^/<prefix>'):

    • API2 owns it natively if nativelyOwnedMethods(app, path, [method]) is non-empty for every Rails method/path pair, and the handler does not call proxyLegacyRailsRequest (public media, Zendesk sidebar, Outreach webhooks and conversation uploads still proxy deliberately). An ALL registration is not ownership.
    • Rails genuinely serves it if any first-party client (rulebase-ui, rulebase-admin, rulebase-beam-web, rulebase-zendesk-app, api2, ML, lambdas), a provider callback, or a documented public path builds the URL. Search with rg -e "['\"]/<prefix>([/'\"?.]|$)" --glob '!**/__generated__/**' --glob '!**/*.test.*' (also check template literals). Leave the prefix and note the caller.
    • Dead if neither of the above holds and the window shows no forwarded request that Rails answered with a non-404 status.

    Both “owned” and “dead” are allowlist decisions: they make the prefix unreachable from the public gateway. Neither certifies that Rails serves nothing under it (see the three legs above).

  3. Add ownership contracts. For a natively owned family, add an exact method/path entry per Rails route to PUBLIC_ROUTE_OWNERSHIP_CONTRACTS in public-route-ownership.ts with the status an unauthenticated request gets and the native structured event (or null). Add at least one expected: "retired" request under the prefix to UNOWNED_PUBLIC_REQUESTS (a method Rails never routed, or any path for a dead prefix). src/app.route-ownership.test.ts executes these against the composed app with the fallback enabled and fails if ownership regresses to ALL, if the handler proxies Rails, or if the gateway would forward.

  4. Move the prefix. Remove it from rails-public-route-prefixes.json, add it to retired-rails-route-prefixes.json (both sorted and unique), then run cd rulebase-web/rulebase-api2 && pnpm typecheck && pnpm test and cd rulebase-api && bin/rspec spec/lint/api2_gateway_compatibility_prefixes_spec.rb.

  5. Verify after deploy. outcome=retired for the prefix should be scanner noise or stale-tab residue only. Any retired event from a current client build or a non-browser caller is a rollback trigger.

Rollback

  • Immediate hold: set LEGACY_RAILS_FALLBACK_UNKNOWN_PATHS=forward on the API2 web service. Under forward the retired and allowlist checks are skipped and every unowned path forwards to Rails again, without a deploy of code.
  • Permanent: revert the retiring PR. This restores the prefix to the allowlist and removes its contracts; the drift spec keeps both files consistent.

2026-09-21 batch

Measurement window. The last gateway-behaviour changes on main before this batch were ef94fa43b (2026-09-20, delete the Action Cable bridge, #12624), bf823fcb0 (2026-09-19, cut off /cable, #12564) and f043a0ca5 (2026-09-19, retire /knowledge_base_documents, #12574); the last flightcontrol.cue change was 0ffdf3d6f (2026-09-19, #12554). The clean window therefore starts at the completion of the 2026-09-20 deploy. Production CloudWatch was not reachable from the authoring session, so the batch below is based on the operator-supplied 24 h census ending 2026-09-21 (1,131 forwarded US requests: /conversations 1,076 uploads, /admin 25, /pages 12, /agent 9, /clients 1; everything except uploads scanner paths) plus static analysis, and is deliberately conservative: only prefixes that are fully owned by API2 or have no caller anywhere in the monorepo were retired. A later operator query of Rails’ own request logs over a clean 44 h window (the second evidence leg, so it includes direct API2 fetches) found Rails serving only conversations (2,002 US), conversation_images (758 US / 275 EU), internal (232 US / 108 EU; gateway-blocked) and integrations (10 US) — none of the 85 retired prefixes appeared. “No caller” covers URL construction in first-party clients and the gateway path only; API2’s direct RAILS_INTERNAL_BASE_URL fetches are being audited in a sibling session and are unaffected by allowlist changes.

Out of scope (sibling sessions): /conversations (live uploads), /conversation_images and /rails (ActiveStorage), /integrations (Slack block actions) stay on the allowlist untouched.

Retired: API2 owns every Rails route natively (25 prefixes)

Every Rails method/path under these prefixes has a method-specific API2 handler that does not proxy Rails; anonymous requests get API2’s 401. Each route is listed in PUBLIC_ROUTE_OWNERSHIP_CONTRACTS.

PrefixRails routesAPI2-ownedCallers (ui/admin/beam/zendesk/api2)
/agent_knowledge_gap_reports221/0/0/0/1
/assignment_queue_memberships111/0/0/0/1
/check_results111/0/0/0/2
/compliance_alerts223/0/0/0/1
/compliance_cases223/0/0/0/1
/compliance_rules222/0/0/0/1
/conversation_assignments222/0/0/0/1
/dashboard_widget_types337/0/0/0/1
/digest_emails441/0/0/0/2
/disputes224/0/0/0/1
/evaluation_contest_feedback_attachments111/0/0/0/1
/evaluation_scorecard_criterion_results222/0/0/0/1
/features112/0/0/0/2
/jira_request_types220/0/1/0/1
/macros221/0/0/0/1
/notion_pages111/0/0/0/2
/organization_data_sources777/0/0/0/3
/permissions112/0/0/0/1
/program_controls332/0/0/0/2
/qa_evaluation_attachments110/0/0/0/1
/reports221/0/0/0/1
/scorecard_evaluation_results112/0/0/0/1
/search111/0/0/0/3
/session118/1/4/0/4
/zendesk_brands222/0/0/0/1

Retired: no caller in either service (60 prefixes)

Rails still routes these, but no file in rulebase-ui, rulebase-admin, rulebase-beam-web, rulebase-zendesk-app, api2, rulebase-ml, lambdas or scripts builds the URL (generated OpenAPI types and tests excluded), and API2 has no handler. /pages is the legacy Pages feature the app navigation guide already marks as removed; its forwarded traffic in the census was /pages/index.astro.mjs.map scanner probes.

PrefixRails routesAPI2-ownedCallers (ui/admin/beam/zendesk/api2)
/agent_knowledge_gaps200/0/0/0/0
/anomaly_detection_rules600/0/0/0/0
/call_scenario_assessment_criteria400/0/0/0/0
/call_scenario_persona_languages100/0/0/0/0
/call_scenario_persona_tones100/0/0/0/0
/call_scenario_personas600/0/0/0/0
/call_scenarios1200/0/0/0/0
/call_scripts600/0/0/0/0
/company_fields600/0/0/0/0
/compliance_reviews700/0/0/0/0
/compliance_rule_documents600/0/0/0/0
/compliance_rule_sections600/0/0/0/0
/content_types600/0/0/0/0
/conversation_cx_risk_snapshot_scorecard_results100/0/0/0/0
/conversation_filters600/0/0/0/0
/conversation_part_call_detail_transcript_messages200/0/0/0/0
/conversation_part_call_details100/0/0/0/0
/conversation_parts200/0/0/0/0
/customer_accounts800/0/0/0/0
/customer_complaint_categories600/0/0/0/0
/customer_complaints1100/0/0/0/0
/demo_audit_events600/0/0/0/0
/evaluation_assignments400/0/0/0/0
/evaluation_batch_assignees100/0/0/0/0
/evaluation_batch_conversations900/0/0/0/0
/evaluation_batches1600/0/0/0/0
/evaluation_compliance_criterion_group_results200/0/0/0/0
/google_sheets_spreadsheets100/0/0/0/0
/group_employees100/0/0/0/0
/group_memberships100/0/0/0/0
/guide_sections600/0/0/0/0
/import_historical_conversations_tasks300/0/0/0/0
/import_knowledge_base_documents_tasks300/0/0/0/0
/knowledge_base_crawl_tasks300/0/0/0/0
/knowledge_base_responses100/0/0/0/0
/knowledge_base_sections800/0/0/0/0
/knowledge_base_sources700/0/0/0/0
/lines_of_business400/0/0/0/0
/manual_evaluations400/0/0/0/0
/marketing_reviews600/0/0/0/0
/pages800/0/0/0/0
/phishing_case_runs400/0/0/0/0
/phishing_case_training_run_answers600/0/0/0/0
/phishing_case_training_runs300/0/0/0/0
/phishing_cases400/0/0/0/0
/pinned_conversations300/0/0/0/0
/procedures700/0/0/0/0
/qa_evaluations300/0/0/0/0
/report_types700/0/0/0/0
/rule_types200/0/0/0/0
/run_conversation_cx_risk_snapshot_tasks300/0/0/0/0
/scorecard_criterion_score_options700/0/0/0/0
/sheet_rows400/0/0/0/0
/simulated_calls600/0/0/0/0
/simulation_assignments600/0/0/0/0
/simulations2600/0/0/0/0
/test_columns400/0/0/0/0
/test_rows400/0/0/0/0
/test_values300/0/0/0/0
/voice_profiles700/0/0/0/0

Kept on the allowlist (73 prefixes as of this batch; 70 after the 2026-09-23 batch below)

Rails still serves at least one route under each of these that a first-party client calls, or the prefix belongs to a sibling migration. /admin (rulebase-admin, Admin::BaseController) and /agent (agent UI, Agent::BaseController) are kept even though their census traffic was scanner noise: both have authenticated Rails routes with live callers, so they are not retired until they are migrated. Since ENG-3385  the scanner round trip is no longer paid either: isScannerShapedPath() answers dotfile segments and .php/.asp/.jsp/.cgi files under any allowlisted prefix (except /rails, whose Active Storage *filename glob is user-controlled) with Rails’ 404 and outcome=rejected: dotfile segments because Rails’ default segment pattern [^\/.?]+ cannot route them, the file suffixes because no Rails route, registered MIME extension or first-party client uses them. Real routes under the same prefixes, and real (.:format) suffixes such as .json/.csv, still forward. /zendesk-app is API2-owned but its sidebar handler still proxies Rails.

PrefixRails routesAPI2-ownedCallers (ui/admin/beam/zendesk/api2)
/accounts30236/0/0/0/8
/admin68714/38/0/0/17
/admin_portal_links101/1/0/0/0
/agent405/0/0/0/6
/agent_performance_reports200/1/0/0/0
/agent_test_columns321/0/0/0/1
/alerts401/0/0/0/0
/api_keys633/0/0/0/2
/assignment_queues878/0/0/0/2
/audit_events211/0/0/0/1
/call_script_guidelines610/0/0/0/1
/calls623/0/1/0/3
/clients622/0/0/0/1
/compliance_agents232223/0/0/0/7
/compliance_criterion_groups653/0/0/0/2
/compliance_policies611/0/0/0/1
/compliance_tests1267/0/0/0/2
/controls435/0/0/0/1
/conversation_cx_risk_snapshots511/0/0/0/1
/conversation_fields600/0/0/0/1 (doc comments only; retired 2026-09-23, see below)
/conversation_images110/0/0/0/6
/conversation_resolution_assignment_rules754/0/0/0/2
/conversation_threads211/0/0/0/1
/conversations664351/1/5/0/60
/customer_fields600/0/0/0/1 (doc comments only; retired 2026-09-23, see below)
/customer_issues811/0/0/0/2
/dashboard_widget_type_groups432/0/0/0/1
/data_sources523/0/0/0/2
/employee_fields600/0/0/0/1 (doc comments only; retired 2026-09-23, see below)
/employees141116/0/0/0/6
/evaluation_contests311/0/0/0/1
/evaluation_sampling_rules756/0/0/0/2
/evaluation_schedules643/0/0/0/1
/evaluation_scorecard_criterion_result_comments532/0/0/0/1
/frameworks211/0/0/0/2
/groups1342/0/0/0/2
/integrations300/0/0/0/2
/invitations1064/0/0/0/2
/jira_issue_drafts850/0/1/0/1
/locations601/0/0/0/1
/memberships875/0/0/0/2
/notification_alert_rules966/0/0/0/2
/organization7611/0/1/0/5
/organizations836/0/0/0/5
/partners754/0/0/0/2
/program_risk_factors431/0/0/0/1
/program_sampling_rules321/0/0/0/1
/program_test_questions322/0/0/0/1
/qa_agent_evaluations1378/0/0/0/4
/qa_evaluation_requests400/0/0/0/1
/rails2321/0/0/0/4
/redaction_rules641/0/0/0/2
/roles656/0/0/0/2
/scorecard_criteria1367/0/0/0/4
/scorecard_criterion_checks611/0/0/0/1
/scorecard_criterion_score_option_justification_options523/0/0/0/2
/scorecard_sections611/0/0/0/1
/scorecards181413/0/0/0/13
/scoring_guides632/0/0/0/2
/sheet_cells422/0/0/0/1
/sheet_columns423/0/0/0/1
/sheet_versions321/0/0/0/1
/sheets141216/0/0/0/7
/sla_policies754/0/0/0/2
/slack_channels212/0/0/0/1
/tags433/0/0/0/3
/teams769/0/0/0/9
/test_reports212/0/0/0/2
/test_risk_scenarios431/0/0/0/1
/trmnl100/0/0/0/0
/webhooks740/0/0/0/14
/work_schedules654/0/0/0/2
/workspaces843/0/0/0/3

2026-09-23 batch: legacy object-field prefixes (ENG-3383 )

Measurement window. No fresh production query was possible from the authoring session (coordinator AWS SSO expired at 05:21Z); the evidence is the already-recorded operator censuses in the retirement plan: across the 24 h windows ending 01:20Z, 03:20Z and 04:20Z the only forwarded request under any of these prefixes was one EU GET /conversation_fields 200, timestamped before the 00:56Z #12820 cutover, and the fully post-canary slice 02:35:13Z → 04:20Z had zero outcome=forwarded events in both regions. /customer_fields and /employee_fields did not appear in any census.

Why they were kept on 2026-09-21. The static classifier counted one API2 “caller” for each (0/0/0/0/1). Re-checked by hand with the step 2 rg pattern: those hits are the doc comments at the top of routes/object-field-reads.ts, routes/object-field-writes.ts and lib/employee-payload.ts naming the Rails paths they replaced — no file in rulebase-ui, rulebase-admin, rulebase-beam-web, rulebase-zendesk-app or api2 builds a /conversation_fields, /customer_fields or /employee_fields URL. Every first-party read and write already goes to the API2-native hyphenated routes (features/objects/api/object-fields.ts, get-conversation-fields.ts, get-customer-fields.ts, features/employees/api/get-employee-fields.ts, features/employee-fields/api/get-employee-fields.ts). /company_fields, the fourth member of the family, was already retired in the 2026-09-21 batch.

Classification. API2 owns the boundary natively under a different path, the same shape as /knowledge_base_documents: index, create, update and destroy map to GET/POST /<type>-fields and PATCH/DELETE /<type>-fields/:id; Rails’ show has no successor and no first-party caller.

PrefixRails routesAPI2 successor routesCallers (ui/admin/beam/zendesk/api2)
/conversation_fields64 (/conversation-fields)0/0/0/0/0
/customer_fields64 (/customer-fields)0/0/0/0/0
/employee_fields64 (/employee-fields)0/0/0/0/0

Contracts. PUBLIC_ROUTE_OWNERSHIP_CONTRACTS gains the object-fields family (the twelve native successor routes, anonymous 401) and UNOWNED_PUBLIC_REQUESTS gains expected: "retired" entries for the legacy paths including the empty-segment /conversation_fields/ shape that the ENG-3371 package previously listed as forwarded. The retired check runs before path normalization, so that shape is now answered locally as well.

Rollback and post-deploy gate. As above. Expected post-deploy signal: outcome=retired under these three prefixes at zero or stale-tab residue only; any retired event from a current UI build is a rollback trigger and means a caller the static search missed.

2026-09-25 batch: resources families with a live wildcard-middleware shadow (ENG-3463 )

Why this batch exists. While checking method parity for the remaining allowlisted resources families, the exact-match variant of step 2 (an ownership check that ignores * registrations) showed that routes/org-directory-mutations.ts registered requireNonAgentMembership with a method-scoped wildcard: router.on(["POST","PATCH","DELETE"], ["/teams/*", "/partners/*", "/memberships/*", "/invitations/*", ...]). createLegacyRailsGateway treats any method-specific registration in matchedRoutes as native ownership, so every unported Rails route under those shapes was answered 404 locally instead of being forwarded — a live dependency, not scanner noise: POST /invitations (InvitationsController#create, no api2 port) and PUT /partners/:id (the Rails resources alias of update). Shadowed requests emit no legacy_rails_gateway event, so no gateway census could have shown this. The static rg search in step 2 does not catch it either: it looks for callers, not for registrations. Step 2 addendum: run the ownership check with wildcard registrations excluded (!route.path.includes("*")) and treat any method-scoped router.on([...], "/<prefix>/*", middleware) as a bug — attach middleware per route (createRoute({ middleware: [...] })) or use an ALL router.use.

Measurement window. No fresh production query was possible from the authoring session (no AWS credentials on the box). The recorded censuses in the retirement plan — 24 h to 07:40Z, 15:40Z and 17:45Z on 2026-09-24 — show zero successful Rails forwards in both regions and every forwarded 404 under /admin or /agent; none of the seven prefixes below appears in any of them. That is consistent with, not proof of, “no live Rails caller”; the classification rests on the exact-method parity below and the static caller search.

Classification. Rails routes from bin/rails routes versus exact (non-wildcard) api2 handlers on the composed app:

PrefixRails method/path pairsapi2 exact handlersGap
/assignment_queues88PUT /:id added in this batch
/compliance_agents2323PUT /:id added
/controls44PUT /:id added
/memberships86PUT /:id added; POST /memberships and POST /memberships/:id/deactivate are routed by Rails but MembershipsController has no create/deactivate action (ActionNotFound → 404), so the local 404 is the same answer
/roles66PATCH /:id added (handler was PUT-only)
/teams77PUT /:id added; GET /teams/download_import_members_template already native
/work_schedules66PUT /:id added

Callers (ui/admin/beam/core/api2 RAILS_INTERNAL_BASE_URL/customer docs) for methods api2 did not already own: 0/0/0/0/0/0 for all seven. /partners and /invitations stay on the allowlist: PUT /partners/:id gains a native alias but POST /invitations (and Rails’ PATCH/PUT/DELETE /invitations/:id, action-less) keep forwarding.

Contracts. PUBLIC_ROUTE_OWNERSHIP_CONTRACTS gains seven session-owned families (61 exact method/path pairs, anonymous 401). UNOWNED_PUBLIC_REQUESTS gains retired entries for the two action-less membership routes and never-routed methods, a normalized entry for /work_schedules/, and forwarded pins for POST /invitations and POST /partners/123/nope so a future wildcard registration fails the ownership test instead of silently shadowing Rails again. Allowlist 71 → 64, retired 89 → 96.

Rollback and post-deploy gate. As above. Expected post-deploy signal: outcome=retired under the seven prefixes at zero or stale-tab residue only. outcome=forwarded for POST /invitations is expected to appear once the shadow is gone — it is the live Rails dependency this batch un-hid, not a regression; it becomes the next port candidate.

2026-09-26 batch: compliance-program resources families with PUT alias parity (ENG-3469 )

Why this batch exists. Re-running step 2 with wildcard registrations excluded on main 53c53de5d showed six compliance-program families whose only Rails-only pair is PUT /:id (the resources alias of update); api2 had registered PATCH /:id only. Adding the alias completes exact method parity, so the prefixes can leave the allowlist.

Measurement window. No fresh production query was possible from the authoring session (no AWS credentials on the box). The recorded censuses in the retirement plan — the 2026-09-24 07:40Z/15:40Z/17:45Z windows and the last recorded window with zero successful Rails forwards — list none of the six prefixes as forwarded. That is consistent with, not proof of, “no live Rails caller”; the classification rests on exact-method parity and the static caller search below. No new zero window is claimed.

Classification. Rails routes from bin/rails routes versus exact (non-wildcard) api2 handlers on the composed app:

PrefixRails method/path pairsapi2 exact handlersGap
/agent_test_columns33PUT /:id added in this batch
/compliance_criterion_groups66PUT /:id added
/program_risk_factors44PUT /:id added
/program_sampling_rules33PUT /:id added
/program_test_questions33PUT /:id added
/test_risk_scenarios44PUT /:id added

Callers (ui/admin/beam/core/api2 RAILS_INTERNAL_BASE_URL/customer docs) for PUT or for the Rails origin: 0/0/0/0/0/0. Every ui caller uses rulebaseApi2.PATCH (features/compliance/api/*, compliance-review-dialog-content.tsx).

Contracts. PUBLIC_ROUTE_OWNERSHIP_CONTRACTS gains six session-owned families (23 exact method/path pairs, anonymous 401). UNOWNED_PUBLIC_REQUESTS gains retired entries for never-routed paths under each prefix (GET /agent_test_columns, POST /compliance_criterion_groups/123/nope, GET /program_risk_factors, GET /program_sampling_rules, GET /program_test_questions, GET /test_risk_scenarios). Allowlist 64 → 58, retired 96 → 102.

Rollback and post-deploy gate. As above. Expected post-deploy signal: outcome=retired under the six prefixes at zero. Still live after this batch: /invitations (POST /invitations, the two template downloads), /partners (GET /partners/:partner_id/employees), /conversations, /accounts, /admin, /agent.

Last updated on