---
title: "Tech Request Portal"
slug: tech-request-portal
section: Governance & Operations
status: stable
description: "The **Tech Request Portal** (repo `itrequestportal`, live at [itrequests.allianceabroad.com](https://itrequests.allianceabroad.com)) is an internal Alliance Abroad staff tool that streamlines technology request submissions to the Tech De…"
last-updated: 2026-10-07
---

# Tech Request Portal

## Overview

The **Tech Request Portal** (repo `itrequestportal`, live at [itrequests.allianceabroad.com](https://itrequests.allianceabroad.com)) is an internal Alliance Abroad staff tool that streamlines technology request submissions to the Tech Department. It is **not part of the Globus/BOP/Applicant/Host/Partner portal family** — it has no relationship to Globus applicants, applications, or the dev/QA/prod Globus environment triad. It is a standalone Cloud Run service with its own repo, its own deploy pipeline, its own database and its own GCP project. [1]

It handles four request categories — **Bug**, **Improvement**, **Idea**, and **DevOps** — and routes each one differently: Bug/Improvement/Idea create an Asana task; and DevOps has its own set of subtypes, one of which (**Deployment Request**) also triggers a live deploy in addition to filing its Asana ticket — a promotion agent, the target repo's own feature-branch workflow, or a pull request the portal opens, depending on environment and service. The former **IT** category (a pre-filled email to the outside IT helpdesk, no ticket) was removed from the form on 2026-10-06; hardware, email and Microsoft 365 problems go to the IT helpdesk directly. [2]

In practice the portal is a **deployment console**: of the first 28 persisted requests (2026-09-16 → 2026-10-04), 24 were QA/Prod Deployment Requests from four staff members. The 2026-10-04 hardening batch (Asana 1219146658634941, 1219137819396804, 1219141521987050, 1219148052453882) was prioritised on that evidence. The staff-facing user guide is at [/docs/tech-request-portal-guide](/docs/tech-request-portal-guide) and is linked from the `?` help control in the portal header and under the sign-in buttons.

## Architecture

### Frontend

React 18 + TypeScript, built with **Vite**, routed with **Wouter**, styled with **Tailwind + shadcn/ui** (Radix primitives), forms via **React Hook Form + Zod**, server state via **TanStack Query**. Purpose-built components: `CategoryCard`, `RequestForm` (every category/subtype has its own conditional field set; freeze-window admin panel lives here too), `SuccessModal` (Asana link + submission-specific "what happens next"), `HelpLink`. Everything under `client/src/components/ui` is generic shadcn scaffolding. [3]

### Backend

**Express + TypeScript (ESM)**. `server/index.ts` is a thin bootstrap: body parsing → `applySecurity` (helmet, CSP, same-origin guard, rate limits — see Security) → `setupAuth` → request logging → `runMigrations()` → `registerRoutes` → Vite dev middleware or static serving. Business logic lives in `server/routes.ts`; the deployment-request lifecycle is split into `server/deployAgent.ts` / `server/prodDeployAgent.ts` (agent triggers + `/check-ci`), `server/devDeployAgent.ts` / `server/devDeploymentLifecycle.ts` (Dev: GitHub `workflow_dispatch` + run tracking), `server/prodReleasePr.ts` (pull requests the portal opens with the GitHub App: release → main for Prod, feature → release for QA) with `server/prodReleasePrLifecycle.ts` / `server/qaReleasePrLifecycle.ts`, `server/githubApp.ts` (GitHub App installation token), `server/branchRoutes.ts` / `server/branchStore.ts` / `server/branchSync.ts` (the synced branch mirror behind the branch pickers, see Branch pickers), `server/deploymentDispatch.ts` (runs the lifecycle off the request path, persists the outcome, announces it), `server/qaDeploymentLifecycle.ts` (environment-parameterised Asana orchestration for the agent-driven QA/Prod paths), `server/asanaComments.ts`, `server/platformPulse.ts` (Teams post), `server/health.ts`, `server/security.ts`, `server/migrate.ts`. [4]

### Shared constants — the single source of truth

`shared/constants.ts` (no ORM imports, so the browser bundle can import it) holds every business list: request categories, DevOps subtypes, portal targets, the deployable services (the same four in every environment; "All services" is no longer offered anywhere since 2026-10-05, Asana 1219157687810147), which services are on the sandbox branching model (`isSandboxModelService`, used as `prodServiceNeedsReleaseBranch`), which services name a QA release branch (`qaReleaseServices`, the same list as the sandbox-model repos — every deployable service since 2026-10-06 — checked by `qaServiceNeedsReleaseBranch` and reused as `qaServiceAcceptsSourceBranch`), the branch-name rules (`isValidDevDeployBranch`, `isValidReleaseBranch`, `isValidQaSourceBranch`), the repos excluded from the QA in-progress poll, the admin rosters (`ENVIRONMENT_ADMINS`, `PROD_DEPLOY_ADMINS`) with case-insensitive helpers and the single `canTriggerDeployment()` rule, and the agent trigger/outcome statuses. `shared/schema.ts` re-exports it for the server; `RequestForm.tsx` and `server/githubQaCheck.ts` import it. Before 2026-10-04 these lists were hand-duplicated in three files with drifting "kept in sync" comments.

### Persistence

Drizzle ORM over **Postgres** (`pg` + `drizzle-orm/node-postgres`, Cloud SQL instance `itrequestportal-db` reached through the Cloud SQL Auth Proxy), gated by `hasDatabase = Boolean(process.env.DATABASE_URL)` in `server/db.ts`. Tables: `tech_requests` (every submission, including the agent run id, trigger status, terminal `agent_outcome` and `agent_completed_at`, and for deployment requests `deploy_environment` / `deploy_branch` / `deploy_source_branch`), `freeze_windows` and `repo_branches` (a mirror of each deployable repo's branches, migration `0004`, see Branch pickers). The legacy `users` table was dropped on 2026-10-04 (SSO is the only sign-in). The development slot uses its **own** Cloud SQL instance (`itrequestportal-dev-db`), never the production one. [5]

**Schema changes ship as committed migrations.** `migrations/` holds drizzle-kit SQL (`npm run db:generate` after editing `shared/schema.ts`); `server/migrate.ts` applies anything pending at boot, before routes are registered, and records it in `drizzle.__drizzle_migrations`. The baseline `0000` is idempotent (`IF NOT EXISTS`) so it was a no-op against the existing production database. `npm run db:push` remains for local prototyping only — never against production. [6]

If `DATABASE_URL` is unset the server falls back to in-memory `MemStorage`, but since 2026-10-04 that state is **not silent**: `GET /health` returns 503 with `storage: "memory"` and the Cloud Run startup probe keeps the revision from becoming Ready.

## Authentication

Passport with **Google** and **Microsoft** OAuth2 strategies, both gated to the allowed sign-in domains (`ALLOWED_EMAIL_DOMAINS` in `shared/constants.ts`: `@allianceabroad.com`, plus `@wildpacks.com` since 2026-10-05, Asana 1219160675912599 — Wild Packs staff are members of the same Entra tenant but carry a different UPN domain). Sessions are **stateless**: the `AuthUser` is signed into an httpOnly JWT cookie (`auth_token`, 12h, `SameSite=Lax`). Either provider can be individually absent. `SESSION_SECRET` is mandatory in production — `setupAuth` throws at boot without it, so a misconfigured revision fails its rollout instead of signing cookies with the in-repo development secret. The development slot signs in with Microsoft only, through its **own** Entra app ("itrequestportal - dev slot", single tenant, `User.Read` only) so feature-branch code never sees a production client secret; Google is off there. [7]

Machine callers authenticate with `x-api-key` (`GATEWAY_API_KEY` for the Alliance MCP Gateway's `itrequest-mcp` connector; `DEV_TO_QA_SYNC_AGENT_API_KEY` for the sync agent calling `GET /api/freeze-windows/active`). Both comparisons are constant-time (`crypto.timingSafeEqual` on equal-length buffers).

## Security baseline (added 2026-10-04)

`server/security.ts`, applied before auth:

- **helmet** defaults plus a real **Content-Security-Policy** built from what the client loads: `default-src 'self'`, `script-src 'self'` (no inline scripts in production), `style-src 'self' 'unsafe-inline' https://fonts.googleapis.com` (shadcn's chart component injects a `<style>`), `font-src 'self' data: https://fonts.gstatic.com`, `img-src 'self' data: https:`, `object-src 'none'`, `frame-ancestors 'self'`, `form-action 'self'`. Development relaxes `script-src`/`connect-src` for Vite HMR only.
- **Same-origin guard**: `POST/PUT/PATCH/DELETE /api/*` carrying the session cookie must present an `Origin` (or `Referer`) whose host equals the request host, else 403 `cross_origin_rejected`. API-key callers send no cookie and are untouched. Per OWASP, `SameSite=Lax` alone is defence in depth, not a CSRF defence.
- **Rate limits** (15-minute window, keyed on the client IP that Cloud Run appends to `X-Forwarded-For`, so a client-prepended header cannot dodge it): `/api/auth/*` 30/IP (`RATE_LIMIT_AUTH_MAX`), `POST /api/tech-requests` 20/IP (`RATE_LIMIT_SUBMIT_MAX`). 429 `rate_limited`.
- Requester-supplied fields are entity-encoded before entering the confirmation email HTML and the Platform Pulse message; the full Asana payload is no longer written to Cloud Logging.

## Request Categories & Routing

### Bug / Improvement / Idea → Asana

Creates an Asana task in project `1211626237526498`, assigned to `1137892504335386`, with category-specific description formatting. Two custom fields — `Executive Sponsor` (hardcoded fallback list) and `System or Project` (no fallback; silently omitted if lookup fails) — are resolved via `GET /api/1.0/projects/:id/custom_field_settings`. Attachments (images, PDF, Word, ≤5 MB; click or drag-and-drop) upload via `multer` memory storage. A SendGrid confirmation email follows a successful create. [2] [9]

### Legacy `IT` category (API only)

The form no longer offers **IT** (removed 2026-10-06). The API still accepts `category: "IT"` from machine callers: it never creates an Asana task, agent run or email, and is only persisted as a row. Do not offer it on the form again.

### DevOps subtypes

`category: "DevOps"` uses its own Asana project/section (`ASANA_DEVOPS_PROJECT_ID` / `ASANA_DEVOPS_SECTION_ID`), no assignee unless the request is business stopping (then `ASANA_ASSIGNEE_ID`), and one of **four** subtypes (`shared/constants.ts: devOpsSubtypes`): **Deployment Request**, **Password Reset / Login Issue**, **New User / Staff Provisioning**, **General Request**. All four create the DevOps ticket; **Deployment Request** additionally triggers a live deploy. "Manage Freeze Window" is a UI-only pseudo-subtype shown to freeze-window admins. [10]

Since 2026-10-04 (Asana 1219137845573621, itrequestportal #112; services per environment from #116; QA source branch from #117 on 2026-10-05) there is **one** deployment subtype instead of two. Picking it reveals, in order:

1. **Environment** (`deployEnvironment`): `Dev`, `QA` or `Prod` — only the environments the signed-in user is on the roster for are offered (`canTriggerDeployment()` is the single rule, client and server).
2. **Service** (`portalTarget`): the same four services in every environment — `BackOfficePrototype`, `PPPToo`, `HostPortalPrototype`, `WildPacksApplicationForm`. One request deploys one service: "All services" was removed from every environment on 2026-10-05 (itrequestportal #121) and is rejected with `invalid_service`; the option labels read "Development — deploy a feature branch to a development slot", "QA — deploy a release branch to a QA slot" and "Production — deploy a release to the live environment". All four services are on the [sandbox branching model](/docs/git-branching-release-model); `BackOfficePrototype` completed its conversion on 2026-10-06 (itrequestportal #124): its QA rail is `release/*` → `deploy-release-qa.yml` → `globus-qa-N`, and its Prod deploy is the release → main pull request, like the other three. The portal itself is **not** one of the deployable services: it deploys through its own rail (see Deployment & Environments).
3. **Branches**, depending on environment and service:
- **Dev**: a **feature branch** (`deployBranch`, required) from the repo's `feature/*` branches, searched on the server through `GET /api/deploy/branches?repo=&kind=feature&q=&page=` (see Branch pickers).
- **QA on any single service, or Prod on a sandbox-model service**: the **target release branch** (`deployBranch`, required, `release/*`, `kind=release`). For QA that branch is deployed to a QA pool slot (`qaReleaseServices` = all four since itrequestportal #118, 2026-10-05); for Prod it is the head of the release → main pull request the portal opens (`prodServiceNeedsReleaseBranch`: all four services since itrequestportal #124, 2026-10-06).
- **QA on any single service**: an optional **source feature branch** (`deploySourceBranch`, `feature/*`). When set, the portal opens (or links the already-open) feature → release pull request with the GitHub App, writes it to the ticket and stops there: merging that PR pushes the release branch, whose deploy workflow deploys the QA slot on its own, so no agent is dispatched for that request. Leaving it empty deploys the release branch as it stands.
- `BackOfficePrototype` in Prod requires the release branch like the other three and opens the release → main pull request (itrequestportal #124, 2026-10-06); the portal never calls the qa-to-prod-sync-agent any more (`prodAgentServices` is empty). A machine caller that omits the QA branch for it no longer gets a development → qa promotion: the dev-to-qa-sync-agent dispatches `deploy-release-qa.yml` at the newest `release/*` branch.

The legacy subtypes **QA Deployment Request** and **Prod Deployment Request** are no longer offered by the form but remain accepted by the API (machine callers, older clients) and are mapped to their environment by `resolveDeploymentEnvironment()`; historical rows keep them in `devops_subtype`. New rows persist `deploy_environment`, `deploy_branch` (migration `0002`) and `deploy_source_branch` (migration `0003`). The `/requests` page titles every deployment row as "<Env> deployment — <service> @ <branch>", with "(from <source branch>)" appended when one was given.

### Business stopping urgency

Bug requests and DevOps requests that are not deployments carry an **Urgency** field (`urgencyLevels`, `supportsUrgency()` in `shared/constants.ts`): `Normal` (the default) or `Business stopping`. Choosing Business stopping reveals two detail fields, **Who is blocked?** and **Blocked since when?**, which the form requires. The server re-validates the urgency with `parseUrgency()` and answers 400 when it is invalid. A business-stopping request:

- is filed with a `[BUSINESS STOPPING]` prefix on the Asana task title and a notes block stating who is blocked and since when;
- sets the target project's `Priority` custom field to the first option found among `Business stopping`, `Critical`, `Urgent`, `Highest`, `High` (best effort; when none matches, a warning is logged);
- is announced in Platform Pulse after the response is sent; the poster is fail-open.

### When Asana cannot create the ticket

If Asana is configured but the create call fails, the request is **not** accepted: the row is persisted with `agentTriggerStatus: "skipped_asana_failed"` (deployment requests) or no trigger status, the response is **502 `asana_unavailable`** with plain-language copy, no agent is triggered and no email is sent. "Asana not configured" (local development) still proceeds without a ticket. Before 2026-10-04 a deployment could run with no ticket and nobody told.

## Deployment Requests — Safety Gates

Run in order before anything is created, for every environment:

0. **Environment resolution** — `resolveDeploymentEnvironment(devOpsSubtype, deployEnvironment)`; a `Deployment Request` with no/unknown environment is 400 `invalid_environment`.
1. **Admin roster** — `canTriggerDeployment(actingEmail, env)`: `ENVIRONMENT_ADMINS[env]` for Dev and QA (the same seven accounts: six people plus the `platform-bot` machine account used for automated browser checks; it is not on the Prod roster), `PROD_DEPLOY_ADMINS` (narrower) for Prod. Session callers are checked by their own email; machine callers must supply `actingEmail`. Server-enforced; the client only uses the roster for UI gating. 403 `not_authorized`.
2. **Service and branch validation** — the selected service must be one of the four (400 `invalid_service` with the valid list; "All services" is rejected). Dev requires a `feature/*` branch (400 `dev_branch_required` / `invalid_branch`). QA on any single service, and Prod on a sandbox-model service, validate the release branch (400 `invalid_branch`); a QA source branch needs the release branch it is promoted into (400 `source_branch_requires_release_branch`) and must be `feature/*` (400 `invalid_source_branch`).
3. **Freeze window** (QA/Prod only) — `storage.getActiveFreezeWindow(env)`; 409 `qa_frozen` / `prod_frozen` with reason and end time. Freeze windows are per-environment and created/deleted only by that environment's admins (confirmed in the UI, times shown with the browser's IANA timezone). A Dev deploy lands a feature branch on a pool slot and is never frozen.
4. **QA-deploy-in-progress** (QA only) — `checkQaDeployInProgress()` polls `deploy-qa.yml` on the repos in `qaDeployReposToPoll` via the `alliance-agent-workflow` GitHub App; 409 `qa_deploy_in_progress`. That list has been empty since itrequestportal #124 (2026-10-06): no repo deploys QA from `deploy-qa.yml` any more, so the check is a no-op until a repo is added back. Fails open on GitHub errors — the freeze window is the hard gate.
5. **Prod confirmation** (client) — a Prod deployment opens a dialog that requires typing the target service name before anything is sent.

The reciprocal gate still stands: the dev-to-qa-sync-agent calls `GET /api/freeze-windows/active` (shared-secret, `x-api-key`) before every run of its own. [14]

## Deployment Request Lifecycle (agent-driven QA)

Dev and the pull-request paths have their own lifecycles, described after this one. Prod used this agent-driven path for `BackOfficePrototype` until 2026-10-06; since itrequestportal #124 every Prod request ends in a release → main pull request instead.

1. **Synchronously**: the Asana ticket is created first, the `tech_requests` row is written with `agentTriggerStatus: "queued"`, and the requester gets the response immediately (ticket link + "what happens next" in the success modal).
2. **After the response** (`server/deploymentDispatch.ts`, never awaited by the handler): the environment's agent `/run` is called with the explicit repo list (QA: `/run {repos, branches}` with the chosen release branch, which the dev-to-qa-sync-agent dispatches as the repo's release workflow — `deploy.yml` for PPPToo, HostPortalPrototype and WildPacksApplicationForm, `deploy-release-qa.yml` for `BackOfficePrototype`, which is kind `release_dispatch` since vertex-agents #285 on 2026-10-06 (#273 had introduced the `release_workflow` field); with no branch every repo deploys its newest release branch. Prod: no agent is called for any of the four services since itrequestportal #124 — Prod opens the release → main pull request described under "Pull-request lifecycles" below); the row is updated with `agentRunId` and `triggered`/`failed`. Then `runDeploymentBackgroundContinuation` posts the promote result, calls `/check-ci` and waits for the real deploy/CI result (up to ~35 min), and either completes the ticket to Done or posts a failure comment and @-mentions the requester.
3. **Terminal outcome is persisted**: `agent_outcome` (`succeeded` | `failed` | `no_changes` | `awaiting_review`) and `agent_completed_at` are written to the row — previously the result existed only as Asana comments.
4. **Platform Pulse**: the outcome is announced in the Platform Pulse Teams channel through the Alliance MCP Gateway (`teams_send_bot_channel_message`, robot `itrequestportal-deploy-notifier`, role `svc_platform_pulse`, PAT in Secret Manager `itrequestportal-gateway-pat`), using the standard HTML format. Fail-open: a Teams/gateway problem never alters the ticket or the row.

This lifecycle **requires `min-instances=1` and CPU always allocated** on the Cloud Run service — both are pinned in `deploy.yml` (and re-applied on every blue-green deploy) since 2026-10-04. Google reclaims an idle instance after 15 minutes even with CPU always allocated, so `minScale: 0` could kill the `/check-ci` wait mid-flight.

## Pull-request lifecycles (QA source branch, Prod on sandbox-model repos)

Two paths end in a pull request rather than a deploy, because the sandbox branching model only moves code between its rails by PR (required checks `quality-gate` + `branch-rules`, one approval, a Code Owner review). The portal opens the PR with the `alliance-agent-workflow` GitHub App (`server/prodReleasePr.ts`, `openPromotionPr`), reuses an already-open PR for the same head instead of duplicating it, links it on the Asana ticket and records `agent_outcome: awaiting_review`; it never approves or merges.

- **QA with a source feature branch** (`server/qaReleasePrLifecycle.ts`, any of the four services): `feature/<source> → release/<target>`. Merging pushes the release branch and its deploy workflow deploys the QA pool slot; the ticket stays open until then.
- **Prod on a sandbox-model repo** (`server/prodReleasePrLifecycle.ts`): `release/<branch> → main` (the chosen branch). Merging runs the production deploy.

A GitHub 422 "no commits between" is reported in plain language (the target already contains the branch). The row keeps `agent_run_id` null for these paths.

## Dev Deployment Lifecycle (sandbox-branching-model repos and BackOfficePrototype)

Repos with a Development pool deploy a `feature/*` branch to a pool slot through their own `deploy-feature-request.yml` workflow (in each target repo, not in itrequestportal), which calls the trusted reusable workflow pinned to `main`. The portal triggers that same workflow on demand instead of waiting for a push:

1. **Synchronously**: same as above — Asana ticket first, row with `agentTriggerStatus: "queued"`, immediate response.
2. **After the response** (`server/devDeploymentLifecycle.ts` via `deploymentDispatch.ts`): `devDeployAgent.triggerDevDeployment()` calls `POST /repos/Alliance-Strategies/<repo>/actions/workflows/deploy-feature-request.yml/dispatches {ref: <branch>}` with the `alliance-agent-workflow` App installation token (the installation has `actions: write`), then finds the run GitHub created (newest `workflow_dispatch` run on that branch created after the dispatch) and records its id as `agentRunId`. The run id is a GitHub Actions run id, not a promotion-agent run id.
3. **Wait**: `waitForDevDeployment()` polls the run every 20 s for up to 30 min. `success` → comment with the run URL, ticket closed to Done, `agent_outcome: succeeded`. Any other conclusion or a timeout → failure comment with the run URL and the usual causes (quality-gate violation, all three dev slots held, branch not cut from `main`), requester @-mentioned, `agent_outcome: failed`.
4. **Platform Pulse**: same announcement, with a Branch row and "Actions run" instead of "Agent run".

Preconditions: the target repo's `deploy-feature-request.yml` must declare `workflow_dispatch:` **on the branch being deployed** (GitHub reads the workflow file from the dispatched ref). All four repos carry the trigger on `main` as of 2026-10-05 (branching-sandbox #35, PPPToo #440, HostPortalPrototype #623, WildPacksApplicationForm #109, BackOfficePrototype #2752); a feature branch cut before its repo gained the trigger returns 422 and the ticket gets a plain-language explanation. If the dispatch is rejected but the push rail has already deployed that exact commit, there is nothing to wait for: the ticket gets a "no new deploy was needed" comment saying the Development pool slot is held for 30 minutes from the last push and can be reassigned after that, the ticket is closed to Done and the outcome is `succeeded`. The `push` trigger remains alongside `workflow_dispatch`; removing it is a separate decision.

The Development slot hostname (`dev-N.<domain>`) is written only to the workflow run's job summary, not exposed by the Actions API — the ticket links the run and says so.

## Branch pickers (searchable branch mirror)

Added 2026-10-09 (Asana 1219360050715555, itrequestportal #144). The Deployment Request form's branch fields (the Dev feature branch, the QA/Prod release branch and the optional QA source feature branch) are **one component**, `BranchCombobox`, over **one endpoint**, `GET /api/deploy/branches`, backed by **one table**, `repo_branches`. `client/src/lib/branchList.ts` is the only place that builds the URL and reads it (`useBranchList`); the form and the picker share that hook, so the unfiltered first page is a single cached request. `server/__tests__/branchSingleSource.test.ts` fails if a second branch-list URL, picker, REST listing or GraphQL walker appears.

**Why a mirror.** The previous picker listed branches through GitHub's REST `git/matching-refs`, which is **not paginated** (it ignores `page`/`per_page` and returns every matching ref on every call). A paging loop therefore re-read the same refs whenever a repo had 100 or more (BackOfficePrototype `feature/` had 132), returning about ten copies of each branch, with duplicate React keys, a silent 1000-ref cap and an alphabetical order that buried new work. Only `feature/*` and `release/*` were ever offered, so `hotfix/*` or any other name could never be found.

**Table `repo_branches`** (migration `0004`): primary key `(repo, name)` (a duplicate is impossible), `kind` (`feature` | `release` | `hotfix` | `other`, from the name prefix), `sha`, `committed_at` (the head commit date; GraphQL cannot order heads by it, so recency is stored), `synced_at`, `deleted_at`. Rows GitHub no longer reports are soft-deleted, and only after a **complete** sync.

**Sync (`server/branchSync.ts`).** One GraphQL `repository.refs(refPrefix: "refs/heads/")` walk per repo, by cursor (no cap), with the `alliance-agent-workflow` installation token. A repo with no stored rows is synced inline on first use (BackOfficePrototype takes about 6 s for its ~830 branches; the other repos 1-4 s); a populated one is served immediately and refreshed in the background when older than 5 minutes. Concurrent callers share one fetch (single-flight), a repo is never fetched more than once per 30 s, and the sync state is read from the database at most every 15 s. An empty answer for a known repo, an HTTP/GraphQL error or a partial walk **never** hides anything: the sync throws and the last rows keep serving. An older, slower sync from another Cloud Run instance cannot overwrite fresher rows or resurrect a branch a newer run hid (guarded in the upsert; ties are ordered in byte order so Postgres and the in-memory twin agree).

**Endpoint.** `GET /api/deploy/branches?repo=&kind=feature|release&q=&page=&limit=` (signed-in session; `limit` capped at 100, default 10; `q` at most 200 characters, 400 `query_too_long`). `q` is split on whitespace and every token must appear in the name (case-insensitive, `%` and `_` literal); an exact name match ranks first, then the newest commit. The response keeps `{success, repo, kind, branches:[{name, sha}], message?}` and adds `total`, `q`, `page`, `pageSize`, `stale`, `syncedAt` and, when rows are shown but the mirror could not be refreshed, `warning`. `q` and `page` echo what the response answers, so the picker labels the rows on screen (not the newest request). With nothing to show, `message` says why (for example the GitHub App is not configured).

**Prod newest release.** `pickNewestReleaseBranch` (used when a machine caller omits the release branch) syncs the repo first and then takes the newest `release/*` row from the same mirror; there is no per-branch commit lookup and no other GitHub listing in the code.

**Verification.** On 2026-10-09 the four services were checked through the real form on the development slot (Dev feature picker, QA release picker, QA source picker: identical results for the same queries, and identical to the API) and, after the production deploy, on production (first real sync: BackOfficePrototype 132 feature and 3 release branches, PPPToo 19 and 1, HostPortalPrototype 140 and 1, WildPacksApplicationForm 14 and 1; each branch once across all pages; the branch that originally could not be found is found by literal name and by ticket number).

## Health & Probes

- `GET /health/live` — process-only, always 200; backs the Cloud Run **liveness** probe so a Cloud SQL blip cannot cause a restart loop.
- `GET /health` — 200 only when storage is the real database, it answers, and the columns the code relies on exist (`tech_requests.agent_outcome`, `tech_requests.agent_completed_at`, `tech_requests.deploy_source_branch`, `freeze_windows.environment`, `repo_branches.deleted_at`); otherwise 503 with `storage`, `reachable`, `schemaOk`, `missingColumns`. Backs the **startup** probe (10 s × 18), so a revision running on the in-memory fallback or against a drifted schema never becomes Ready. The 2026-09-21 freeze-window 500s were exactly such a drift.
- `GET /api/version` (added 2026-10-07) — unauthenticated, `Cache-Control: no-store`, returns `{sha, ref, env, slot}` from the `GIT_SHA` / `GIT_REF` / `DEPLOY_ENV` / `DEPLOY_SLOT` build args baked into the image. The post-deploy check (`scripts/deploy-plain.sh`) greps it for the full commit SHA, and it answers "which commit is this slot or production serving?".

## Deployment & Environments

Since 2026-10-07 (Asana 1219250483984214) the portal follows a **dev-only variant of the [sandbox branching model](/docs/git-branching-release-model)**: one development slot, **no QA environment and no `release/*` branches**. `main` is production. [1] [15]

```
main ──┬── feature/<16-digit ticket>-<name> ──push──▶ dev slot itrequestportal-dev-1
       │            └── PR straight into main  (quality-gate, branch-rules, test, CodeQL; 1 approval + Code Owner)
       └── hotfix/<16-digit ticket>-<name> ──PR into main──▶ (deploys nothing by itself)
main ──merge──▶ production blue-green (Cloud Run itrequestportal)
```

**Production rail** (`.github/workflows/deploy.yml`, on every push to `main`, i.e. every merged PR): `quality` (the repository quality gate) and `app-checks` (`npm ci` → `npm run check` → `npm test`) must pass, then `deploy` authenticates through the Workload Identity pool `gh-itrequests-prod`, which only accepts **this workflow file on `refs/heads/main`**, as the deployer service account `itrequestportal-deployer-prod` (not the runtime account). It builds the image with `GIT_SHA`, syncs the `ASANA_TOKEN` secret version (the deployer may only add versions to that one secret) and runs `scripts/blue-green.sh`: deploy the new revision at 0% behind a `g-<sha12>` tag, send a 10% canary that must be **observed** serving the new SHA (`GET /api/version`) with every probe healthy, then 100%, tagging the previous revision `blue`; any failure restores the recorded traffic split, and the workflow repeats that restore if the step is killed. The green revision keeps `--min-instances=1 --no-cpu-throttling`, the `/health` startup and `/health/live` liveness probes and the Platform Pulse PAT binding. Because the service ingress is `internal-and-cloud-load-balancing`, neither the `*.run.app` URL nor a revision tag URL is reachable from a CI runner, so the script skips its private tag-URL smoke test (`SMOKE_TAG_URL=false`) and runs the canary through the public host (`CANARY_URL`); green is judged by its startup probe (real database + schema) plus the canary. Ingress is deliberately not widened to make a smoke test easier.

**Development slot**: Cloud Run `itrequestportal-dev-1` (`dev-1.itrequests.allianceabroad.com`), claimed by pushing a conforming `feature/*` branch (or `workflow_dispatch`) through `deploy-feature-request.yml` → `trusted-deploy-dev.yml@main` (the pool `gh-itrequests-dev` accepts only that reusable workflow on `feature/*` refs, as `itrequestportal-deployer-dev`). The slot is held for 30 minutes from the last push; a second branch is refused until it expires. It is **isolated by construction**: its own Cloud SQL instance (`itrequestportal-dev-db` — a second database on the production instance would not be isolation), its own session secret and Entra client, and **no** Asana, GitHub App, SendGrid, Platform Pulse or agent secrets, so a dev deploy can never file a real ticket, trigger a real deployment, post to Teams or touch production data. Consequently the Deployment Request flow cannot be exercised there, and the branch pickers only list what is already in the dev database's `repo_branches` table (the slot cannot sync from GitHub; seed rows to test them).

**Board**: [development.itrequests.allianceabroad.com](https://development.itrequests.allianceabroad.com) is the [environment index](/docs/git-branching-release-model) (staff SSO), defaulting to this repo's dev view; QA is shown as "No QA environment".

**Enforcement** (GitHub rulesets, no bypass actors): `Branch names (creation allow-list)` allows only `main`, `feature/<16 digits>-*`, `hotfix/<16 digits>-*` and `dependabot/*` (anything else, including `release/*`, is rejected on creation); `Protect main` requires `quality-gate`, `branch-rules`, `test` and `CodeQL`, one approval and a Code Owner review. `branch-rules` allows only feature/hotfix/dependabot → `main` and checks the branch was cut from `main`. The quality gate (`scripts/quality-gate.mjs`, rules in `config/quality-gate.config.json`) checks file length, layer imports and forbidden patterns, freezes the eight files that were over the 300-line limit on conversion day (`max-lines-legacy-*`, they may only shrink), and reports `protected-paths` for any change to workflows, the gate, CODEOWNERS, rulesets or `AGENTS.md` until a maintainer applies the `governance-approved` label and a Code Owner reviews.

**Terraform**: `alliance-gcp-infra/environments/platform-services/itrequestportal.tf` (service, runtime account, database, secrets) and `itrequestportal-sandbox.tf` (dev slot, deployer accounts, WIF pools, hostnames, dev OAuth app) declare the resources but they are not yet imported/applied (Asana 1218064958717286); the dev-slot resources were created with gcloud on 2026-10-07. Hand-applied items the import must absorb: Secret Manager secret `itrequestportal-gateway-pat` + `secretAccessor` for the service account; min-instances / CPU allocation / probes (now also in `deploy.yml`). The previous shared `github-actions` pool binding on `itrequestportal-sa` and the project-wide `secretAccessor` stopgap are still in place and should be retired now that the dedicated deployers are live.

## Configuration Reference

| Variable | Used by | Behaviour |
|---|---|---|
| `DATABASE_URL` | `server/db.ts` | Secret-backed. Unset → in-memory storage and `/health` 503. |
| `SESSION_SECRET` | `server/auth.ts` | **Required in production** (boot fails without it). |
| `GOOGLE_OAUTH_CLIENT_ID` / `_SECRET` / `_CALLBACK_URL`, `MICROSOFT_OAUTH_CLIENT_ID` / `_SECRET` / `_TENANT_ID` / `_CALLBACK_URL` | sign-in | A provider is disabled if its vars are missing. |
| `ASANA_TOKEN`, `ASANA_PROJECT_ID`, `ASANA_SECTION_ID`, `ASANA_ASSIGNEE_ID`, `ASANA_DEVOPS_PROJECT_ID`, `ASANA_DEVOPS_SECTION_ID`, `ASANA_DEVOPS_DOD_SECTION_ID`, `ASANA_DEVOPS_WORKFLOW_STATUS_FIELD_ID`, `ASANA_DEVOPS_WORKFLOW_STATUS_DOD_OPTION_ID` | Asana | Token + project missing → "unconfigured" (no ticket, request proceeds). Token present but create fails → 502. |
| `SENDGRID_API_KEY`, `SENDGRID_FROM_EMAIL` | confirmation email | Missing → email skipped. |
| `GITHUB_APP_ID`, `GITHUB_APP_PRIVATE_KEY` | QA in-progress check (read), branch sync (GraphQL `refs`, read), Dev `workflow_dispatch` (`actions: write`), opening QA/Prod pull requests (`pull_requests: write`) | Missing → QA check skipped (fails open); branch sync skipped, so the pickers serve the last synced rows or an explanatory message; Dev dispatch and PR opening fail closed (`failed`, ticket told). |
| `DEV_TO_QA_SYNC_AGENT_API_KEY`, `PROD_DEPLOY_AGENT_API_KEY` | agent triggers / reciprocal freeze endpoint | Missing → trigger returns `failed`. |
| `GATEWAY_API_KEY` | `requireAuthOrApiKey()` | Gateway connector's key; unset → API-key path never matches (does not fail open). |
| `PLATFORM_PULSE_GATEWAY_PAT`, `PLATFORM_PULSE_GATEWAY_URL` | `server/platformPulse.ts` | PAT missing → announcement skipped (logged). URL defaults to `https://mcp.allianceabroad.com/mcp`. |
| `GIT_SHA`, `GIT_REF`, `DEPLOY_ENV`, `DEPLOY_SLOT` | `GET /api/version` | Build args baked into the image by the deploy workflows; default `unknown` / `null` / `prod` / `null`. |
| `RATE_LIMIT_AUTH_MAX`, `RATE_LIMIT_SUBMIT_MAX` | `server/security.ts` | Defaults 30 and 20 per 15 min per IP. |

Error codes a client can receive from `POST /api/tech-requests`: `Invalid request data` (400), `invalid_environment` / `invalid_service` / `dev_branch_required` / `invalid_branch` / `source_branch_requires_release_branch` / `invalid_source_branch` (400), `not_authorized` (403), `cross_origin_rejected` (403), `qa_frozen` / `prod_frozen` / `qa_deploy_in_progress` (409), `rate_limited` (429), `asana_unavailable` (502). The client maps each to plain-language copy and shows a sign-in banner on 401.

## Extending the Portal

- **New request category or DevOps subtype:** add it in `shared/constants.ts`, branch on it in `createAsanaTask()`'s description chain in `server/routes.ts`, add fields in `RequestForm.tsx` (keep "Additional Comments" last).
- **New deployable service:** add it to `deployServices` in `shared/constants.ts`; `sandboxModelServices` (drives the Prod release → main PR path) is `[...deployServices]` and `qaReleaseServices` (drives the QA release/source branch pickers and the QA PR path) is `sandboxModelServices`, so both pick it up — the dev-to-qa-sync-agent must know the repo's release workflow (`kind: release_dispatch`, or `release_workflow` on a `promote` entry). The dropdowns, the server validation, the branch endpoint's allow-list and the in-progress poll all follow. A Dev repo must have `workflow_dispatch:` on its `deploy-feature-request.yml`.
- **New admin:** `ENVIRONMENT_ADMINS` / `PROD_DEPLOY_ADMINS` in `shared/constants.ts` — still a code change + deploy, not runtime config.
- **New schema change:** edit `shared/schema.ts`, `npm run db:generate`, review the SQL, commit; the server applies it at boot. Add any column the code cannot run without to `REQUIRED_COLUMNS` in `server/health.ts`.
- **New DevOps action that calls a live service:** follow `deployAgent.ts` / `deploymentDispatch.ts` — one trigger module, dispatch off the request path, persist the result on the row, announce via `platformPulse.ts`, never throw past the caller.
- **New Asana custom field:** reuse `getCustomFieldInfo(fieldName)`.
- **Local dev:** `npm run dev`; `npm run check`, `npm test` and `node scripts/quality-gate.mjs --static-only` must be green (the deploy and PR gates enforce them). The repo is checked out with CRLF line endings on Windows; a worktree needs its own `npm ci`.
- **Shipping a change:** cut `feature/<16-digit ticket>-<name>` from `main`, push (deploys the dev slot), open a PR into `main`; merging runs the production blue-green. See the `itrequestportal-ops` skill for the full runbook.

## Known Gotchas

- **In-memory fallback is loud now, not silent.** `GET /health` → 503 `storage: "memory"` and the startup probe fails the rollout. If a revision will not become Ready, check `/health` on it first.
- **Admin rosters are code, not config.** Adding a deployer means editing `shared/constants.ts` and deploying.
- **`System or Project` custom field has no fallback.** Unlike Executive Sponsor, it is silently omitted if the Asana lookup fails.
- **Every deployment request writes to two systems and three records.** Asana ticket (comments, completion), the `tech_requests` row (`agent_outcome`), and a Platform Pulse message — all driven from `deploymentDispatch.ts`.
- **A Dev deploy's slot URL is not in the API.** The trusted workflow writes `dev-N.<domain>` to the job summary only; the ticket links the run. First hit on a freshly deployed slot cold-starts for 5–10 s.
- **Dev dispatch needs the trigger on the deployed branch.** A feature branch cut before the repo's `deploy-feature-request.yml` gained `workflow_dispatch:` returns GitHub 422 "Workflow does not have 'workflow_dispatch' trigger" — rebase the branch on `main`, do not edit the workflow on the branch (protected path).
- **A QA request with a source branch does not deploy anything by itself.** It opens the feature → release PR; the QA slot deploys only when that PR is merged (release push). The ticket stays `awaiting_review` until then, and a second request for the same branches links the same PR instead of opening another.
- **The production `*.run.app` URL answers 404 from outside the load balancer** (ingress is `internal-and-cloud-load-balancing`). Probe production through `itrequests.allianceabroad.com`; do not "fix" a 404 on the run.app URL by widening ingress.
- **The portal's own development slot has one slot and 30-minute holds.** A second feature branch pushed inside the hold fails at "Reserve the dev slot" and names the holder; it never evicts a live hold.
- **The domain shares the `nexus` load-balancer IP** (`35.190.51.227`); host-based routing distinguishes them, DNS does not.
- **Browser verification of authenticated flows** has no non-personal SSO identity for this portal; Sam's own account is never used for testing. Form-flow changes are covered by typecheck/build and the server test suite (226 tests as of 2026-10-06), by machine-auth probes through `POST /api/tech-requests`, and verified visually on the next real submission.

- **Never list branches from GitHub REST for a picker.** `git/matching-refs` is unpaginated; page parameters are ignored. All branch lists come from `repo_branches` (GraphQL, cursor-paged), and the single-source test enforces it.
- **The first picker open on a repo syncs inline.** Expect a few seconds once per repo per cold mirror (about 6 s for BackOfficePrototype); after that searches are database-only. The scheduled reconcile (Asana 1219360474073654) is not built yet, so the mirror refreshes on use (5 minute TTL).
- **The dev slot's mirror is empty by design.** It has no GitHub App, so its branch pickers show an explanatory message until rows are seeded into `itrequestportal-dev-db`.

## References

[1]: https://github.com/Alliance-Strategies/itrequestportal/blob/main/.github/workflows/deploy.yml "itrequestportal deploy workflow (quality gate + app checks + blue-green)"
[2]: https://github.com/Alliance-Strategies/itrequestportal/blob/main/server/routes.ts "itrequestportal route handlers and Asana task creation"
[3]: https://github.com/Alliance-Strategies/itrequestportal/blob/main/client/src/components/RequestForm.tsx "itrequestportal RequestForm component"
[4]: https://github.com/Alliance-Strategies/itrequestportal/blob/main/server/index.ts "itrequestportal server bootstrap"
[5]: https://github.com/Alliance-Strategies/itrequestportal/blob/main/server/storage.ts "itrequestportal storage abstraction (DbStorage / MemStorage)"
[6]: https://github.com/Alliance-Strategies/itrequestportal/blob/main/server/migrate.ts "itrequestportal migrate-on-boot"
[7]: https://github.com/Alliance-Strategies/itrequestportal/blob/main/server/auth.ts "itrequestportal Google/Microsoft OAuth + stateless JWT cookie auth"
[9]: https://github.com/Alliance-Strategies/itrequestportal/blob/main/server/sendgrid.ts "itrequestportal confirmation email"
[10]: https://github.com/Alliance-Strategies/itrequestportal/blob/main/shared/constants.ts "itrequestportal shared constants (categories, subtypes, services, rosters)"
[11]: https://github.com/Alliance-Strategies/itrequestportal/blob/main/server/security.ts "itrequestportal security baseline"
[12]: https://github.com/Alliance-Strategies/itrequestportal/blob/main/server/githubQaCheck.ts "itrequestportal QA-deploy-in-progress GitHub App check"
[13]: https://github.com/Alliance-Strategies/itrequestportal/blob/main/server/deploymentDispatch.ts "itrequestportal deployment dispatch (outcome + Platform Pulse)"
[14]: https://github.com/Alliance-Strategies/itrequestportal/blob/main/server/platformPulse.ts "itrequestportal Platform Pulse announcement"
[15]: /docs/onboarding-cicd-deployment "CI/CD & Deployment Process"
[16]: https://github.com/Alliance-Strategies/itrequestportal/blob/main/server/health.ts "itrequestportal health endpoints"
[17]: https://github.com/Alliance-Strategies/itrequestportal/blob/main/server/devDeployAgent.ts "itrequestportal Dev deployment (workflow_dispatch + run tracking)"
[18]: https://github.com/Alliance-Strategies/itrequestportal/blob/main/server/prodReleasePr.ts "itrequestportal pull requests opened by the portal (release -> main, feature -> release)"
[19]: https://github.com/Alliance-Strategies/itrequestportal/blob/main/server/branchSync.ts "itrequestportal branch mirror sync (GitHub GraphQL refs -> repo_branches)"
[20]: https://github.com/Alliance-Strategies/itrequestportal/blob/main/client/src/lib/branchList.ts "itrequestportal shared branch-list URL builder and hook"
