---
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`, `SelfServicePanel` (rendered on the Home page instead of the request-type step when the Self Service service is selected; the service step receives `showSelfService` from `isSelfServiceAdmin(user.email)`), `TestCasePanel` (rendered on the Home page instead of the request-type step when `isTestCaseService(selectedService)` is true), `AfterHoursBanner` (rendered on the Home page). Everything under `client/src/components/ui` is generic shadcn scaffolding. [3]

### Backend

**Express + TypeScript (ESM)**. `server/index.ts` is a thin bootstrap: body parsing (JSON bodies up to 512kb) → `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/slotRoutes.ts` / `server/slotState.ts` (the pool-slot picker and its hold checks, see Slot picker), `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 five 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/slots.ts` (since 2026-10-15) holds the pool-slot rules — `slotEnvironments` (Dev, QA), `deploySlotOptions` (`1`..`3`), `isValidDeploySlot` — in its own file because the quality gate caps application files at 300 lines. `shared/schema.ts` re-exports the constants for the server; `RequestForm.tsx` and `server/githubQaCheck.ts` import them. 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` / `deploy_slot`), `freeze_windows`, `repo_branches` (a mirror of each deployable repo's branches, migration `0004`, see Branch pickers), `allowed_domains` (sign-in domains added through the Self Service Terminal, with who added each) and `allowed_domain_events` (their audit trail). 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). `@allianceabroad.com.mx` and `@teacherlounge.org` are also on the list (Asana 1219698570719652; an interim list, pending a tenant-claim check). Both strategies decide sign-in with the async `isSignInAllowed` from `server/signInDomains.ts`; domains added through the Self Service Terminal are stored in the `allowed_domains` table on top of that static list. 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). The one machine caller that uses neither key is Cloud Scheduler on `POST /api/internal/branches/sync`, which authenticates with a Google-signed OIDC token verified in the app (see Branch pickers). The portal is itself a machine caller of the environment index: it mints a Google-signed ID token for its runtime service account from the Cloud Run metadata server to read `GET /internal/slots` (see Slot picker).

## 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. A deployment (`isDeploymentTrigger`) also calls `notifyDeploymentRequest` (from `server/deploymentNotice.ts`) without awaiting it, passing the requester's name and email, environment, service, branch, source branch, slot, Asana task id and URL, and the agent trigger status. "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; slot from #149 on 2026-10-15) 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 five services in every environment — `BackOfficePrototype`, `PPPToo`, `HostPortalPrototype`, `WildPacksApplicationForm`, `PartnerPortal`. 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.
4. **Slot** (`deploySlot`, Dev and QA only; since 2026-10-15, Asana 1219533920331714, itrequestportal #149/#150): which pool slot the deployment lands on — see Slot picker below. Hidden when a QA source feature branch is chosen (the merge push allocates) and never shown for Prod.

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`), `deploy_source_branch` (migration `0003`) and `deploy_slot` (migration `0005`). The `/requests` page titles every deployment row as "<Env> deployment — <service> @ <branch>", with "(from <source branch>)" and "→ slot N" appended when given.

### Slot picker ("Choose slot", 2026-10-15)

Every Dev and QA deployment lands on one of a repo's pool slots (`dev-N` / `qa-N`; three per pool, except BackOfficePrototype Dev which has six since 2026-10-15, Asana 1219535658787900, and PartnerPortal, which has two Dev slots and one QA slot — environment-index is the source of each pool's size). Since Asana 1219533920331714 the requester names that slot, holds are respected, and a one-slot pool defaults to its slot. The pieces, from the deploy rail upwards:

- **Repo workflows** (branching-sandbox #55 as the reference, then WildPacksApplicationForm #139/#140, PPPToo #466/#467, HostPortalPrototype #641/#642, BackOfficePrototype #2905/#2906): `deploy-feature-request.yml`, `deploy-hotfix-qa-request.yml` and the QA release workflow (`deploy.yml`; BOP `deploy-release-qa.yml`) take a `workflow_dispatch` input `slot` (`auto`, `1`, `2`, `3`; default `auto`); the trusted workflows take it as a `workflow_call` input, validate it and pass it to the allocator as `REQUESTED_SLOT`. `scripts/allocate-slot.sh` (Cloud Run) and `allocate-slot-gke.sh` (BOP) then claim **exactly** that slot: renew it if this branch already holds it, take it over if its reservation expired, and **refuse** (run fails, nothing deployed, no other slot touched) when another branch holds it, when the slot cannot be read, or when another branch wins the claim race. A push always allocates `auto`. The trusted half runs from `main`; the caller half must be on the branch being deployed, so the release branches were backported the same day.
- **environment-index** (#27): `GET /internal/slots?repo=&env=dev|qa` returns the board's stored slot rows (`slot`, `host`, `held`, `free`, `branch`, `reservedUntil`, `sha`, plus `poolSize`, `holdsVisible`, `updatedAt`, `stale`) to a machine caller presenting a Google-signed ID token for the index's audience whose email is on `SLOT_READER_SA_EMAILS` (the portal's runtime SA `itrequestportal-sa@`). Holds are **not visible** for the GKE (BackOfficePrototype) pools, which the index probes rather than reads labels from.
- **dev-to-qa-sync-agent** (vertex-agents #327): `POST /run` takes `slots: {repo: "N"}` next to `branches`; a pinned slot becomes `inputs.slot` on the release workflow dispatch. A release branch whose workflow predates the input makes GitHub reject the dispatch (`Unexpected inputs`), reported as an error row — never silently unpinned.
- **Portal server**: `server/slotState.ts` (`fetchSlotState`: mints the ID token from the metadata server — `ENVIRONMENT_INDEX_URL`, local override `ENVIRONMENT_INDEX_ID_TOKEN` — and **fails open**: an unreadable index yields `holdsVisible: false` plus a message; `findSlotConflict`: held by another branch while holds are visible), `server/slotRoutes.ts` (`GET /api/deploy/slots?repo=&env=`, signed-in session; `checkRequestedSlot()`, the submit-time rules). `POST /api/tech-requests`: `deploySlot` must be a slot number up to the largest pool (`MAX_SLOT_POOL_SIZE` = 6 in `shared/slots.ts`) and, once environment-index has answered, up to that pool's `poolSize` (400 `invalid_slot` listing the valid slots; itrequestportal #151); dropped for Prod; refused with a QA source branch (400 `slot_not_applicable`); refused when the index shows another branch holding it (409 `slot_held` with `slot`, `holder`, `reservedUntil`); blank or `auto` means automatic allocation (machine callers, older clients). Dev sends `inputs: {slot}` on the `workflow_dispatch`; QA sends `slots` to the agent. The Asana title ends "→ slot N" with a **Slot** line, the lifecycle comments say "(slot N)" / "succeeded on Development pool slot N", and the Platform Pulse post carries a Slot row.
- **Form**: `client/src/lib/slotState.ts` (`useSlotState` over the one endpoint, `slotOptions`, `defaultSlot`) and a **Slot** select in `RequestForm.tsx`. Rows read `Slot N — free`, `Slot N — held by this branch (the deploy renews it)`, `Slot N — held by <branch> until <time>` (disabled) or `Slot N — free (last held by <branch>, hold expired)`. The preselected slot is the one the chosen branch already holds, else the first free one; it re-targets when the branch changes. A **one-slot pool is fixed** to its slot. When **every slot is held by another branch the submit is blocked** with the holders' expiry. When holds are not visible every slot is offered with a notice that the deploy still refuses a held slot.
- **Hold** (Asana 1219953281858269, 2026-10-28, raised because the fixed 30-minute development hold disrupted testing): a "Hold the slot for" select next to the Slot on Dev (30 minutes default, 1 h, 2 h, 4 h, 8 h, 24 h) and on QA without a source branch (24 hours default, 2 days, 3 days, 7 days), in `client/src/components/HoldSelect.tsx` over `shared/slots.ts` (`holdMinutesOptionsByEnvironment`, `defaultHoldMinutes`, `requestedHoldMinutes`). Only a non-default value is stored (`deploy_hold_minutes`, migration 0011) and sent — Dev as the `hold_minutes` input next to `slot` on the `workflow_dispatch`, QA as the agent's `holds` map next to `slots` — so a branch whose workflow predates the input keeps deploying with the default; a non-default hold on such a branch fails plainly (GitHub 422 "Unexpected inputs", the message names the input) and never takes the push-rail "already deployed" shortcut. `checkRequestedHold()` in `server/slotRoutes.ts`: 400 `invalid_hold` with the environment's list, 400 `hold_not_applicable` for a QA request with a source branch (the merge push allocates with the default, as with the slot), ignored for Prod. The Asana title ends "(hold 4 hours)" with a **Hold** line, the lifecycle comments state the real hold instead of "30 minutes", and the notice email and Platform Pulse post carry a Hold row. The deploy workflows bound the value again and their allocator never shortens a hold the branch already has.

The index is advisory; the allocator is the authority. A slot that looks free in the picker but is taken before the deploy reaches it makes the run fail at "Reserve pool slot" rather than move the deployment elsewhere.

**Verified live on 2026-10-15** (production portal, signed in as the platform bot): a Dev request for WildPacksApplicationForm `feature/1219534186005416-slot-probe` with slot 2 (request `7fac2faf…`) dispatched run 38719020782 with `inputs.slot = 2`; the trusted workflow logged "Reserved requested slot 2 … until 10:47:43Z" and deployed `wildpacks-dev-2`; the index then showed slot 2 held by that branch and the row ended `succeeded`. A second branch asking for slot 2 was refused with 409 `slot_held` naming the holder and expiry. The first live run also found that the client hook's two-element query key produced a malformed URL (the picker fell back to plain labels); fixed in #150.

### 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 five (400 `invalid_service` with the valid list; "All services" is rejected). Dev requires a `feature/*` or `hotfix/*` 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. **Slot** (Dev/QA only, when one was chosen) — `checkRequestedSlot()`: `1|2|3` (400 `invalid_slot`), not with a QA source branch (400 `slot_not_applicable`), not held by another branch while the index can see holds (409 `slot_held`). Prod drops the field.
4. **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.
5. **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.
6. **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, slots}` with the chosen release branch and, when one was chosen, the slot; the dev-to-qa-sync-agent dispatches 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 (marks it complete and sets its Workflow Status to Completed (DoD); it is not moved to a Completed (DoD) section and stays in the section it was filed in; if the completion call fails, it is retried once after 1500 ms with only `completed: true`, without the Workflow Status field, and each failed attempt is logged with its HTTP status) 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. The request's on-behalf label (`onBehalf`, built by `onBehalfLabel(validatedData)` when the request is dispatched) is passed to the announcement together with the requester's name and email. 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 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 (a repeat request's Asana link is appended to that PR's body unless the body already contains the task id), 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. The PR body's merge-effect text is built by `qaMergeEffect` from the release branch and whether `context.carried` is true. No slot can be chosen on this path.
- **Prod on a sandbox-model repo** (`server/prodReleasePrLifecycle.ts`): `release/<branch> → main` (the chosen branch). Merging runs the production deploy. The lifecycle computes `carried` with `requestIsCarried(repos)` and passes it to `openReleaseToMainPr`. When it is true (the release carry will approve and merge the PR itself), the PR body's merge-effect text (`prodMergeEffect`) no longer says the merge is a human action, and the ticket's review comment (`prodReviewComment`) is built with the same flag. Otherwise the legacy wording is used.

A GitHub 422 "no commits between" is reported in plain language (the target already contains the branch). That result carries `reason: "no_commits"` and is not counted as a failure; on the Prod path, when every service reports it, the lifecycle comments that there is nothing to deploy, completes the ticket (`completeAsanaTaskToDoD`) and records `agent_outcome: no_changes`. 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/*` (or `hotfix/*`) 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>, inputs: {slot, requested_by}}` (`inputs` only when at least one is present; `requested_by` is the login `deploymentOwnerLogin(repo, branch, requesterEmail)` resolves, when it returns one, so their reserved slot is used) 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 (and the slot, when chosen), 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 — or the chosen slot taken before the deploy reached it — branch not cut from `main`), requester @-mentioned, `agent_outcome: failed`.
4. **Platform Pulse**: same announcement, with a Branch row, a Slot row when chosen, 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), and — for a chosen slot — the `slot` input. All four repos carry both on `main` as of 2026-10-15; a feature branch cut before its repo gained the trigger returns 422 "Workflow does not have 'workflow_dispatch' trigger", and one that predates the `slot` input returns 422 "Unexpected inputs" when a slot is chosen. Both are reported on the ticket in plain language; a pinned request is never retried unpinned. The one retry is for `requested_by`: when a dispatch that carried it gets a 422 whose body mentions `Unexpected inputs` and `requested_by`, the dispatch is sent again without it (logged as an unreserved slot). If an **unpinned** 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 to the workflow run's job summary; with a chosen slot the ticket names it directly.

## 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).

**Scheduled reconcile (itrequestportal #148, 2026-10-14, Asana 1219360474073654).** Cloud Scheduler job `itrequestportal-branch-sync` (project `alliance-platform-services`, `us-central1`, every 15 minutes, UTC) calls `POST /api/internal/branches/sync` through the public host so the mirror tracks GitHub (new, renamed and deleted branches) even when nobody opens a picker. The route reuses `syncRepoBranches` for the four deployable services, so it is single-flight with the on-demand sync, refuses an empty GitHub answer and soft-deletes only after a complete walk; one repo failing never stops the others. It answers 200 with per-repo `{fetched, hidden}`, 502 if any repo failed (per-repo `error`), and 503 `github_app_not_configured` on a server with no GitHub App (the development slot, by design). The caller is **not** a signed-in user and uses **no shared key**: the job presents a Google-signed OIDC token for the role-less service account `itrequestportal-branch-sync@alliance-platform-services.iam.gserviceaccount.com`, and `server/schedulerAuth.ts` verifies it in the app (RS256 only, Google's published certs, issuer `accounts.google.com`, audience `https://itrequests.allianceabroad.com`, `email_verified` and the exact service-account email); anything else is 401. The job sends an explicit `{}` body because Google's front end answers 411 to a POST without a `Content-Length`. The job and service account were created with gcloud and are documented in `alliance-gcp-infra` `environments/platform-services/itrequestportal.tf` (that root module is not applied by CI).

**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. (`deploy_slot` is nullable and deliberately not in the required list.)
- `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 plain deploy (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/deploy-plain.sh`: deploy the new revision with `--no-traffic`, wait until that revision (taken from `status.latestCreatedRevisionName`) is Ready, send it 100% by name, and verify that the public host reports the new SHA (`GET /api/version`) on consecutive probes; any failure restores the recorded traffic allocation, and the workflow repeats that restore if the step is killed. Rollback after a good deploy is a redeploy of the previous commit. The new 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 verification runs through the public host; the new revision is judged by its startup probe (real database + schema) plus the SHA check. 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 a change to workflows, the gate, CODEOWNERS, rulesets or `AGENTS.md` cannot merge until a Code Owner reviews it.

**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`. |
| `ENVIRONMENT_INDEX_URL`, `ENVIRONMENT_INDEX_ID_TOKEN` | `server/slotState.ts` | Index base URL (default the service's `run.app` URL, which is also the ID-token audience); the token override is for local development only (`gcloud auth print-identity-token`). Unreadable index → slot picker offers every slot with a notice; the deploy workflow still refuses a held slot. |
| `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` / `invalid_slot` / `slot_not_applicable` (400), `not_authorized` (403), `cross_origin_rejected` (403), `qa_frozen` / `prod_frozen` / `qa_deploy_in_progress` / `slot_held` (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:` (and the `slot` input) on its `deploy-feature-request.yml`, and the environment index must know its pools for the slot picker to show holds.
- **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 plain deploy. See the `itrequestportal-ops` skill for the full runbook.
- **Mind the line-length ratchet:** `server/routes.ts`, `RequestForm.tsx`, `deploymentDispatch.test.ts` and five other files are frozen at their conversion-day size and every other file is capped at 300 lines, so new server logic goes in its own module (as `slotRoutes.ts` / `slotState.ts` / `shared/slots.ts` did) and new tests in a new file.

## 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 comes from the chosen slot or the job summary.** With a chosen slot the ticket names `dev-N`; without one the trusted workflow writes the hostname to the job summary only and the ticket links the run. First hit on a freshly deployed slot cold-starts for 5–10 s.
- **Dev dispatch needs the trigger (and the `slot` input) 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"; one that predates the `slot` input returns 422 "Unexpected inputs" when a slot is chosen — rebase the branch on `main`, do not edit the workflow on the branch (protected path). A pinned request is never retried without its slot.
- **The slot picker is advisory; the allocator is the authority.** The picker reads the environment index (minutes old at worst, holds invisible for GKE pools); the deploy workflow refuses a held slot at claim time. A chosen slot that looks free but is taken first fails the run rather than moving the deployment.
- **TanStack queries here must use a one-element `queryKey`.** The app's default `queryFn` fetches `queryKey.join("/")`; a second element is appended to the URL. The slot hook shipped with `[url, repo]` and silently fell back to plain labels until #150.
- **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. No slot can be chosen on that path.
- **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** uses the `platform-bot@allianceabroad.com` machine account (Microsoft sign-in, on the Dev/QA rosters, not Prod); Sam's own account is never used for testing. Form-flow changes are covered by typecheck/build and the server test suite, by machine-auth probes through `POST /api/tech-requests`, and by a signed-in Playwright pass on production when the change touches the deployment form.

- **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, and the 15-minute scheduled reconcile keeps the mirror current between uses.
- **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 + plain deploy)"
[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"
[21]: https://github.com/Alliance-Strategies/itrequestportal/blob/main/server/slotState.ts "itrequestportal slot hold state (environment-index reader, fail-open)"
[22]: https://github.com/Alliance-Strategies/itrequestportal/blob/main/server/slotRoutes.ts "itrequestportal GET /api/deploy/slots and the submit-time slot rules"
[23]: https://github.com/Alliance-Strategies/environment-index/blob/main/src/triggers.ts "environment-index machine endpoints (refresh, webhooks, GET /internal/slots)"
