Skip to content

System Overview

The plain-English version of this page is Why FoundryGate?; the one-picture version is Architecture at a glance, generated from infra/ and src/; the full platform detail lives in Feasibility & Strategic Direction and the reference section.

FoundryGate is a governance and access-management layer for Azure AI Foundry. It provisions the gateway and the model deployments — Claude models are set up by the infrastructure deploy, and admins manage OpenAI model deployments from the UI — then adds per-developer budget enforcement, individual key lifecycle management, and Entra-driven provisioning on top of them.

System diagram

The system splits into two planes. The data plane is the APIM v2 gateway: every developer request passes through it, and every limit is enforced there, in real time. The control plane is the FoundryGate app: it never sees a model request — it decides who gets a key, which tier product that key lives under, and reconciles what the gateway reported afterwards. Solid arrows are the request path; dashed arrows are out of band.

FoundryGate system diagram — data plane and control plane

FoundryGate system diagram. Real-time path: developer CLIs (Claude Code sends x-api-key, Codex CLI sends api-key) call the Azure API Management v2 gateway over HTTPS with a personal subscription key. The gateway has 2 front doors, /anthropic and /openai/v1, requires the subscription to belong to a tier product (Standard 5M tokens per month and 20K tokens per minute, Power 20M tokens per month and 40K tokens per minute, Unlimited with no monthly cap and 100K tokens per minute), applies the tier model allowlist and the llm-token-limit policy which returns 403 when the monthly quota is exhausted and 429 with Retry-After when the per-minute cap is hit, then routes with managed identity to backend pools with circuit breakers (Anthropic pool: priority 1 eastus2, priority 2 swedencentral; single OpenAI backend) and on to Azure AI Foundry accounts in eastus2 (primary) hosting claude-sonnet-4-5, claude-haiku-4-5, claude-opus-4-5, gpt-4-1-mini; swedencentral (pool-member) hosting claude-sonnet-4-5, claude-haiku-4-5, claude-opus-4-5. Gateway diagnostics stream LLM logs and token metrics to Log Analytics and Application Insights. Control plane, out of the request path: Entra ID signs users in and is the source of users and groups; the Blazor UI on Azure Static Web Apps calls FoundryGate.Api on Azure Container Apps, which stores state in Azure SQL through EF Core; FoundryGate.Functions run the monthly reset, usage reconciliation (reading ApiManagementGatewayLlmLog from Log Analytics, every 15 minutes) and an Entra directory sync daily at 02:00 UTC that reconciles users then groups from Microsoft Graph; Key Vault holds the APIM key-encryption key (no client secrets; Graph is called as each host's own managed identity). The API manages APIM subscriptions through the Management API: create under a tier product, regenerate both keys, move tier, delete. FoundryGate.Functions reaches the same Management API for one operation only — moving a subscription to another tier product when a reset resolves a new tier. Legend: solid azure arrows are the real-time enforcement path on the request; dashed grey arrows are telemetry and reconciliation, out of band; dashed amber arrows are control-plane calls for lifecycle and tiers.

Developer workstations
Claude Code → x-api-key · Codex CLI → api-key · one personal APIM subscription key per developer
HTTPS — every request carries the developer's key
Data plane Azure API Management v2 · the request path · enforcement happens here
Front door /anthropic
Anthropic Messages API — Claude Code
Front door /openai/v1
OpenAI Responses / Chat Completions — Codex CLI
fg-require-product — a key with no tier product is refused (403)
Standard
5M tokens / month
20K tokens / minute
Power
20M tokens / month
40K tokens / minute
Unlimited
no monthly cap
100K tokens / minute
Tier products — one llm-token-limit policy each, counter keyed on the subscription
model alias + allowlist → 403 monthly quota exhausted 429 + Retry-After per-minute cap
gateway managed identity → Foundry (no account keys anywhere)
Anthropic backend pool
priority 1eastus2 → priority 2swedencentral
circuit breaker per backend: 3 × 429/5xx in 30 s trips it, honours Retry-After · retry ×2 across the pool
OpenAI backend
primary region only
Azure AI Foundry — eastus2 (primary)
claude-sonnet-4-5 · claude-haiku-4-5 · claude-opus-4-5 · gpt-4-1-mini
Azure AI Foundry — swedencentral (pool-member)
claude-sonnet-4-5 · claude-haiku-4-5 · claude-opus-4-5
APIM diagnostic settings — LLM logs + token metrics, emitted by the gateway asynchronously
Log Analytics + Application Insights
ApiManagementGatewayLlmLog — per-request prompt / completion tokens, joined to the subscription (billing-grade) · llm-emit-token-metric — near-real-time dashboards
APIM Management API — API: create subscription under a tier product · regenerate both keys · move tier · delete
Functions: move tier only, when a reset resolves a new one
KQL rollup of ApiManagementGatewayLlmLog → usage reconciliation, every 15 min
Control plane FoundryGate app · out of the request path · lifecycle, tiers, reporting
Microsoft Entra ID
users · groups · sign-in · Graph sync
JWT
FoundryGate.Web
Blazor WASM · Static Web Apps
REST
FoundryGate.Api
ASP.NET Core · Container Apps · managed identity
EF Core
Azure SQL
Users · Groups · QuotaAllocations · Requests · AuditLog
FoundryGate.Functions
MonthlyReset — daily at 00:01 UTC, on the configured reset day · UsageSync — every 15 minutes, reconciliation
EntraSync — daily at 02:00 UTC: users then groups, from Microsoft Graph
.NET isolated worker + storage account (runtime, job locks) → Azure SQL via EF Core
Moves a subscription's tier product when a reset resolves a new one
Azure Key Vault
fg-apim-key-encryption key · no app secrets (Graph uses the API identity)
@KeyVault(...) references resolved at startup
real-time enforcement — on the request path telemetry & reconciliation — out of band control-plane calls — lifecycle & tiers Layout hand-drawn; the regions, tiers, model deployments, front-door paths and Function schedules above are generated from infra/ and src/ at build time.

Two details on the diagram are ground truths worth stating plainly:

  • Enforcement is the solid path. llm-token-limit in each tier product’s policy decides 403 / 429 on the request itself, keyed on the developer’s APIM subscription. Nothing in the control plane is consulted per request.
  • Reconciliation is the dashed path. The Functions read the gateway’s ApiManagementGatewayLlmLog table to keep QuotaAllocation.TokensUsed, dashboards, and cost figures authoritative. If that sync stopped, enforcement would continue unchanged.

The layout of the diagram above is hand-drawn, but the values in it that also exist in the repository — the regions and their model deployments, the tier limits, the front-door paths, the pool priorities and the Functions’ schedules — are generated from infra/ and src/ at build time, so they cannot drift from main.bicep. The fully generated picture is Architecture at a glance; the deploy pipeline, quota chain and key lifecycle diagrams further down this page are conceptual by nature — they show sequences and decisions, not deployed resources.

Live evidence. Responses captured from real deployed gateways — the 429s, the 403s, the header measurements — are written up in validation/ in the repository, one file per gateway cycle.


Entra ID sync — how access follows your org chart

FoundryGate uses the Microsoft Graph API to keep developer access in sync with your Entra directory. It calls Graph as its own managed identity, granted read-only Graph application roles — there is no client secret to store or rotate. This is how access governance works without any manual admin work after initial setup.

First login — automatic provisioning

When a developer signs in through the Blazor UI for the first time, GET /users/me triggers the full provision pipeline:

First login — provision pipeline
  1. 1
    Extract Entra object ID from the bearer token
    The oid claim from the validated JWT identifies the developer uniquely.
  2. 2
    Look up developer in the FoundryGate database
    Query Users table by Entra object ID.
  3. 3
    If not found — fetch from Graph and create user record
    Call Graph GET /users/{oid} to fetch displayName, mail, employeeId. INSERT User row and resolve the monthly token quota (five-level resolution) to a tier product.
    Graph GET /users/{oid}
      → INSERT User row
      → resolve quota → tier (standard / power / unlimited)
  4. 4
    Create APIM subscription via Management API
    Call APIM Management: POST /subscriptions, scoped to the developer's tier product. Name: foundrygate-{userId}. The product's llm-token-limit policy is what will enforce their budget.
    APIM Management API
      POST /subscriptions
        name:  foundrygate-{userId}
        scope: /products/{tier}
  5. 5
    Store encrypted APIM key and create quota allocation
    Key is wrapped with a Key Vault key before writing to SQL. INSERT QuotaAllocation for the current billing period.
  6. 6
    Return UserProfileDto
    Response includes the masked key and current quota usage. The developer is ready to call Foundry.

The developer never needs to contact an admin. Their key is ready the moment they first open the app.

Entra group sync — keeping a team’s roster in step

Admins link a FoundryGate group to an Entra group id. POST /groups/{id}/sync-entra (or POST /groups/sync-entra for every linked group) reconciles that group’s membership — and, through it, the budget its members resolve to:

Entra group sync
  1. 1
    Read the Entra group's members
    Transitively, so a nested directory group flattens to the people inside it. Devices and service principals are ignored.
    Graph GET /groups/{entraGroupId}/transitiveMembers?$select=id
  2. 2
    Match them to FoundryGate users by EntraObjectId
    Directory members with no user row are skipped, never invented, and counted as skippedUnknownUserCount. A non-zero count means those people have never signed in — run POST /users/sync first, then sync again.
  3. 3
    Add and remove memberships, then re-resolve
    Joiners get a GroupMember row with no admin attribution (the directory is the actor, not the admin who pressed the button); leavers lose theirs. Everyone whose membership moved is re-resolved for the current month, which moves their APIM subscription onto the tier product their new budget maps to.
    GroupMember INSERT / DELETE
    QuotaAllocation re-resolved
    APIM: subscription → tier product

Bulk user sync

POST /users/sync pages through all users assigned to the FoundryGate service principal via Graph and upserts them. New users get a record with the default quota; departed users run the same deprovision pipeline an admin deactivation does — subscription deleted, allocation hard-stopped, pending requests rejected — so the sync and the admin button can never drift apart. Useful for an initial import of your entire org before any developer has logged in.

Each departure is its own unit of work, deliberately: a subscription deletion cannot be rolled back, so batching several into one transaction would mean a single failed ARM call erasing the record of deletions that had already happened. A departure the gateway refuses is counted (failedCount), logged, and retried on the next run — the rest of the batch still lands.

Assignments granted to a group count as assignments to its people: each one is flattened to the group’s transitive members (nested groups included) and merged with the directly assigned users, so assigning SG_AI_Developers to the enterprise application is a fully supported setup rather than a partial one. The one case that changes behaviour is a group the API cannot read — that run has an incomplete picture of who is assigned, so it adds and updates but deactivates nobody and says so (skippedGroupAssignmentCount).

Neither sync waits for an admin to remember it: EntraSyncFunction runs both nightly at 02:00 UTC, users first and then every linked group, so joiners have accounts and leavers have lost their keys by morning. The scheduled jobs are described in full further down.


Five-level quota resolution

When a developer’s allocation is created or updated, IQuotaResolutionService walks this chain — first match wins — and the result selects the tier product the developer’s APIM subscription lives under:

Quota resolution — five levels to a tier product

Quota resolution. Five levels, first match wins: 1, User.IsUnlimited gives unlimited; 2, a user-specific MonthlyTokenQuota; 3, any group with IsUnlimited gives unlimited; 4, the highest group MonthlyTokenQuota; 5, the SystemConfiguration default. The resolved budget selects a tier product: Standard at 5 million tokens per month and 20 thousand tokens per minute, Power at 20 million and 40 thousand, or Unlimited with no monthly cap and 100 thousand tokens per minute. The developer's APIM subscription is created under, or moved to, that product, and the product's llm-token-limit policy enforces it in real time: 403 when the monthly quota is exhausted, 429 with Retry-After on the per-minute cap. The subscription state never changes.

  1. 1 User.IsUnlimited = true Unlimited (no monthly cap)
  2. 2 User.MonthlyTokenQuota is set User-specific budget
  3. 3 Any group the user belongs to has IsUnlimited = true Unlimited (via group)
  4. 4 Max(Group.MonthlyTokenQuota) across all user groups Highest group budget
  5. 5 SystemConfiguration["DefaultMonthlyTokenQuota"] System-wide default

First match wins — evaluation stops at the first level that resolves.

resolved budget → tier product — the control plane maps the value to the tier that carries it
Standard
5M tokens / month
20K tokens / minute
Power
20M tokens / month
40K tokens / minute
Unlimited
no monthly cap
100K tokens / minute
APIM subscription created under — or moved to — that product
Enforced by the product's llm-token-limit policy, per subscription, in real time
403 · monthly quota exhausted 429 + Retry-After · per-minute cap subscription state unchanged

The resolved value is written to QuotaAllocation for the current period, and the subscription is created under (or moved to) the matching tier product. Admins can see which level resolved each developer’s quota in the user detail view. Tiers exist because APIM’s token-quota attribute accepts only literals — one policy cannot read a per-user number — so each tier is a product with its own rendered policy.

A budget is a tier. Resolution picks a tier, not just a number: every quota the control plane accepts must equal a configured tier’s cap (5M → Standard, 20M → Power, with the shipped caps) or be unlimited, so the number written to the allocation and the tier the gateway enforces are the same thing. Admins pick from GET /quota/tiers rather than typing a figure. A legacy or hand-edited value that matches no tier is never rejected on read — it is enforced at the next tier up and flagged gateway-capped so an admin can correct it. The allocation records the level, the tier, and the flag, and moving the developer’s subscription onto the tier product is what actually changes their budget.

Resets never zero usage. The gateway’s monthly window resets itself at the UTC month boundary, so the monthly job and the admin’s manual POST /quota/reset only (re)resolve every active developer’s allocation for the current UTC month: new rows start at zero, existing rows are re-resolved but keep their reconciled usage, and the hard-stop mirror is cleared. Running it twice in a month is safe and audited once per run.


APIM enforcement — how limits actually block traffic

FoundryGate doesn’t rely on a database flag being checked on every request — and it no longer relies on a polling loop either. Enforcement lives in the gateway’s llm-token-limit policy, keyed on each developer’s APIM subscription:

  • Monthly budget: token-quota + token-quota-period="Monthly" — the developer’s requests get 403 the moment the budget is exhausted, in real time, on the request path. Quota windows are UTC calendar months, so they reset themselves at month start.
  • Burst control: tokens-per-minute — a per-developer TPM cap returns 429 + Retry-After for one developer’s runaway agentic loop without touching anyone else’s traffic. Claude Code and Codex CLI both back off on this automatically.
  • Both work on the Anthropic Messages path (Claude Code) and the OpenAI path (Codex CLI) — the LLM policies understand both payload schemas on APIM v2 tiers.

Quota tiers are modeled as APIM products (Standard / Power / Unlimited): each product’s policy carries that tier’s budget and TPM cap, and changing a developer’s quota means moving their subscription between products. Subscription suspension is reserved for deactivation and offboarding — not quota exhaustion.

Which models a tier may use is enforced in the same place. Each tier product carries a model alias map — an APIM named value holding { alias: { deployment, backend, provider } } — and a policy fragment that runs before llm-token-limit rewrites the alias a developer pinned (sonnet, haiku, gpt) to the real Foundry deployment and routes it at the right backend. The map is the allowlist: an alias a tier does not list is refused with 403 model_not_permitted, ahead of the quota policy, so a blocked model costs the developer nothing. Because it lives in a named value rather than in policy text, admins edit it from the Models & access page (/models) through the APIM Management API — granting a tier a model, or re-pointing an alias at a newer deployment, takes effect without redeploying a policy or an infrastructure template. The API validates before it writes: an alias must be lower-case and url-safe, and it must point at a deployment the configured Foundry accounts actually have — in every account when it routes at the multi-region Anthropic pool, because the pool sends a throttled request to another region and a region missing the deployment would turn a 429 into a 404.

The usage-sync Function still runs, but as reconciliation, not enforcement. It is a .NET isolated worker on a Flex Consumption plan — scale-to-zero, so it costs nothing between ticks — reading the gateway’s own log with the Function App’s managed identity. The query it runs is a plain .kql file checked into the repo, so an operator can paste it into the Log Analytics blade and get exactly the number the Function got:

UsageSyncFunction — reconciliation, every 15 minutes (and the closed month, for three days)
  1. 1
    Query ApiManagementGatewayLlmLog for per-request token counts
    The gateway's LLM log table records prompt/completion/total tokens per request. One request can span several entries, so they are collapsed to one row per CorrelationId first, then joined to ApiManagementGatewayLogs — the only table that knows which APIM subscription made the call.
  2. 2
    Overwrite QuotaAllocation.TokensUsed in SQL
    Period totals, not deltas — so a re-run, or a catch-up after an outage, converges on the same numbers rather than double-counting. These are the figures the UI, dashboards and audit trail report.
  3. 3
    Record drift; never enforce
    A developer whose reconciled usage exceeds their budget is logged as drift with the delta, because the tier product's policy should already have returned 403. The sync never suspends anyone and never sets the hard-stop flag — that flag means offboarding, not "over budget".

The monthly reset is the other scheduled job. It wakes every day at 00:01 UTC and acts only on the day of the month the fork configured (ResetDayOfMonth, the 1st by default), so changing that setting takes effect the next day rather than at the next deployment:

MonthlyResetFunction — 00:01 UTC daily, acting on the configured reset day
  1. 1
    Is this period still owed a reset?
    Runs on or after SystemConfiguration[ResetDayOfMonth] (1–28) when this month has no reset in the audit log yet, so a tick lost to an outage is picked up by the next day rather than costing the month its reset. One replica then takes a short blob lease, renewed while held, so two instances waking together cannot both run — though the reset is idempotent either way.
  2. 2
    Close quota requests the period left behind
    Any increase request still Pending from a month that has ended becomes Rejected, with a system note and no reviewer. Those requests can no longer be approved anyway — approval refuses a closed period — so leaving them would just mean the review queue starts every month with last month's dead entries in it. First, before anything can touch the gateway, so a failure here costs nothing.
  3. 3
    Resolve quota for the new period and upsert QuotaAllocation
    For each active developer, run the five-level quota resolution for the new month and insert their row. A row that already exists is re-resolved but keeps its reconciled TokensUsed — zeroing it mid-period would only make the dashboard disagree with the gateway.
  4. 4
    Clear hard-stop mirrors, and move any tier that changed
    Gateway token-quota counters reset themselves at the UTC month boundary, so there is no counter to clear — the Function clears the IsHardStopped mirror so dashboards start the month clean. If re-resolving turns up a developer whose tier product changed, it moves their APIM subscription too, and commits that move with the audit row describing it before starting the next one. A move the gateway refuses is recorded and skipped: that developer keeps the tier APIM is still enforcing, and the rest of the run finishes.
  5. 5
    Write the audit log entries
    One quota.monthly-reset row carrying the counts, committed in the same transaction as every allocation it describes — plus a second quota.requests-expired row when the sweep above actually closed something (a sweep that finds nothing writes no row at all). The same code runs behind an admin's manual POST /quota/reset, which audits as quota.reset and names the admin.

The third scheduled job is the directory sync: nightly at 02:00 UTC, EntraSyncFunction reconciles users from Microsoft Graph and then group memberships — in that order, because group sync skips directory members who have no FoundryGate account yet — so a fork’s roster stops drifting from Entra between admin button presses. It is the same code an admin’s POST /users/sync and POST /groups/sync-entra run, with no human actor on the audit row, and a developer the directory no longer lists is deprovisioned in full: their APIM subscription is deleted, not merely flagged. It does nothing at all — one Information line, no error — on a fork that has left Entra:Enabled off.


APIM key lifecycle

A developer’s key is an APIM subscription, created under their tier product. Only people-lifecycle events change its state — there is no suspended state:

APIM key lifecycle state machine

From No key, first login or admin provision creates an APIM subscription under the developer's tier product and the key becomes Active. While Active: rotating the key regenerates both the primary and the secondary key and stays Active; changing the quota re-scopes the subscription to the new tier's product and stays Active (the gateway's monthly counter restarts for that subscription; live validation tracked in #88); a 403 for an exhausted monthly quota or a 429 with Retry-After for the per-minute cap are gateway responses that change nothing. Admin deactivation, Entra departure, or admin key revocation delete the subscription: Deleted. Re-activation runs a fresh provision, reusing an orphan subscription if one exists, and returns to Active. There is no suspended state.

Scroll the diagram sideways to see the whole state machine.

Rotate — POST /keys/me/rotate (or an admin’s POST /keys/{userId}/rotate) calls regeneratePrimaryKey and regenerateSecondaryKey on the subscription: the primary key the developer holds is replaced instantly and shown once, and the secondary — which FoundryGate never issues or stores — is replaced too, so no credential outlives a rotation. The subscription stays active with its counters intact. Stored keys are wrapped with a Key Vault RSA key before they reach SQL; GET /keys/me shows only the last four characters, and POST /keys/me/reveal decrypts on demand (audited) rather than caching anything in the browser.

Both of those routes are capped per signed-in person by the control plane — five reveals and three rotations a minute — because a stolen access token could otherwise replay them for the plaintext key indefinitely, leaving nothing behind but a long run of audit rows. Over the cap the control plane answers 429 with a Retry-After header. The admin routes are not capped: an admin rotating a compromised team’s keys is exactly the work a cap would obstruct.

Change quota — the control plane re-scopes the subscription to the new tier’s product. The new product’s llm-token-limit applies from the next request, and because the gateway’s monthly counter is keyed on the subscription within a product, the developer’s used-so-far count restarts for the new tier (live validation of the mid-month move is tracked in #88). No state change.

Quota exhausted / burst — the gateway’s token-quota returns 403 for the rest of the calendar month; its per-minute token cap returns 429 + Retry-After. The subscription stays active; the key keeps working the moment the window resets. Nothing to undo.

Two different subsystems can answer 429, and the URL tells them apart: one from the gateway origin means the developer’s per-minute token budget is spent, and one from the control plane (/api/v1/keys/me/*) means they asked to reveal or rotate their key too often. Both carry Retry-After; only the first has anything to do with quota.

Delete — admin deactivation (POST /users/{id}/deactivate), Entra departure, or admin key revocation (DELETE /keys/{userId}): DELETE /subscriptions/{sid} removes the subscription entirely and the key stops working immediately. Re-activation (POST /users/{id}/activate) runs the provision pipeline again — reusing an orphaned subscription if a previous deprovision left one behind — and issues a new key.


Deploy pipeline

This is the pipeline FoundryGate is built around, as fixed in CONVENTIONS.md §CI/CD — GitHub Actions with OIDC federated credentials and no long-lived Azure secrets. What runs today on every pull request: ci.yml (build, tests, format), claude-review.yml, and — by path — actionlint.yml, the Docker image build, a Bicep what-if under a read-only identity, and a Static Web Apps preview of the UI. What exists on main for the merge path: one workflow, deploy-all.yml, which runs the whole ordered chain (infra → database → API → Functions ∥ UI → post-deploy tests → summary) against dev. The single-component workflows deliberately have no push trigger — they share a per-environment concurrency lock, and three of them racing meant one deploy was silently cancelled. They stay as workflow_dispatch buttons for redeploying one component. Gating it all: six GitHub Environments (dev, dev-plan, ui-preview, production, dev-destroy, prod-destroy); dev is restricted to protected branches, so no pull request can reach the identity that is Owner on the subscription. The two Environments a pull request can reach are deliberately tiny: ui-preview holds a single custom role — FoundryGate SWA Preview Publisher, defined in infra/modules/swa-preview-role.bicep — assignable only on the dev Static Web App, and dev-plan was meant to be Reader-and-nothing-else for the PR what-if. That second one turned out to be impossible — ARM’s what-if preflight authorizes every resource as a write, so no read-only identity can run it — and the PR what-if is switched off pending a decision (#229). A pull request runs its own copy of the workflow files, so the identity, not the workflow, is the boundary. The owner-side OIDC setup is done for dev (the Owner Setup Runbook is the commands that did it); and the whole chain has now run green against Azure on dev — infra, database, API, UI, Functions and post-deploy tests (#105, closed). It took five attempts, and each failure was a real defect the 1545 hermetic tests could not see: a gateway template that could only be applied once, two Azure regions closed to new SQL servers, a Graph role with no application-type variant, a smoke test that started the container with no configuration, and a CLI projection that made the Functions check unpassable. Production has never been deployed. Badges on the diagram mark which is which; the CI/CD reference lists every workflow, trigger and variable.

Deploy pipeline — the GitHub Actions flow

Two tracks. Track one runs on every pull request today (ci.yml and claude-review.yml): build with warnings as errors, predeployment tests, format check, Claude review. Depending on which paths a pull request touches it also gets a Bicep what-if posted as a comment, run under a read-only identity; a Docker image build with a local health check, which uses no credentials at all; actionlint over the workflow files; and a Static Web Apps preview of the UI. The preview is the only thing on the pull request track that writes to Azure, and all it can write is a staging slot on the dev Static Web App: its identity holds one custom role assignable on that one resource and nothing else. Merging to main is pull-request-only. Track two, on merge, is one workflow, deploy-all.yml: the fully automated deploy chain with OIDC and GitHub Environment gates from dev to production. A plan stage that touches nothing prints the approval sequence first, then infra, then database (whitelist runner IP, wait for firewall propagation, probe port 1433, deploy the dacpac with data-loss blocking, seed reference data and test data outside production, grant the API and Functions managed identities their contained database users, and remove the runner's firewall rule again), then the API, then Functions and UI in parallel, then post-deploy tests, then a summary. Functions and UI wait for the API because on the very first deploy the API stage re-runs the infrastructure template to replace the placeholder image. A production run stops at the reviewer gate six times, once per pending job, and the plan stage lists them. Every stage has run green against Azure on dev, which is what the badges mark.

1 · Pull request runs today — ci.yml + claude-review.yml · cancel-in-progress
  1. Build
    dotnet build — warnings are errors
  2. Pre-deploy tests
    xUnit · SQLite in-memory · convention tests
  3. Format check
    dotnet format --verify-no-changes
  4. Claude review
    formal review on every PR, reads the test artifacts
path-triggered on the same PR
  • Bicep what-if — infra/** — posted as a PR comment · read-only dev-plan identity
  • API image build — Dockerfile — docker build + local /health 200, no Azure
  • UI preview — FoundryGate.Web/** — dev SWA staging slot · ui-preview identity, one resource
  • Workflow lint — .github/** — actionlint + shellcheck + script tests
merge to main — PR-only · required check build-test · GitVersion Mainline
2 · Merge to main CONVENTIONS §CI/CD — automatic · OIDC only · Environment gates dev → production · one workflow, deploy-all.yml, runs the whole chain · never cancelled mid-flight
  1. Plan
    ungated · no Azure · prints the production approval sequence before the first gate
  2. Infra
    Bicep, subscription scope · what-if first · Claude deployments create-once
    validated on dev · 2026-09-05
  3. Database
    reusable database workflow
    1. whitelist runner IP
    2. wait 60 s for firewall
    3. probe TCP 1433
    4. deploy dacpac (DacFx, data-loss blocked)
    5. seed reference → seed test (non-prod)
    6. grant identities (contained SQL users)
    7. remove runner firewall rule
    validated on dev · 2026-09-05
  4. API
    image → ACR → Container Apps · replaces the day-0 placeholder
    validated on dev · 2026-09-05
  5. Functions + UI
    in parallel, after the API
    • Functions → Flex Consumption
    • UI → Static Web Apps
    validated on dev · 2026-09-05
  6. Post-deploy tests
    401 auth contract + smoke test · reporting only until #139
    validated on dev · 2026-09-05
  7. Summary
    job summary · Environment deploy record
    validated on dev · 2026-09-05
validated on dev · 2026-09-05 the stage has run green against Azure on dev, not just linted (run 33979330341; #105). Production has never been deployed.

The database stage is the one with moving parts: the runner’s IP is whitelisted on the SQL server, the workflow waits for firewall propagation and probes port 1433, then the FoundryGate.Database dacpac is deployed with DacFx (data-loss blocked unless a reviewed allow-data-loss override is passed) and reference data is seeded. Two steps then close the loop that Bicep cannot: the API and Functions managed identities get their contained database users (db grant-identities — an Entra-only SQL server cannot grant a managed identity access through ARM), and the runner’s own firewall rule is removed again, along with any left behind by a cancelled earlier run. Bicep re-runs pass createAnthropicModelDeployments=false because Claude model deployments are create-once under ARM, and always pass the image the API is currently running so a re-run never resets it. OpenAI-format deployments are exempt from that flag — they re-PUT idempotently, so ARM keeps them present on every run rather than leaving a missing one for nobody to notice (#259). That makes the ownership split explicit: Claude deployments belong to the infrastructure deploy end to end (created once, never re-PUT, never deleted from the app), while the API and admin UI list everything and manage OpenAI-format deployments (/api/v1/foundry/deployments, see the API reference). Code deploys read resource names from the infra deployment’s outputs rather than from GitHub variables — the only variables are the three OIDC identifiers and the Entra client ids.


Solution projects

ProjectRole
FoundryGate.ApiREST API + services, Azure Container Apps, Entra auth, APIM management-plane calls
FoundryGate.DataEF Core entities, AppDbContext, seeding — schema ships as a dacpac, no EF migrations
FoundryGate.DomainShared DTOs and enums — no framework deps
FoundryGate.CoreServices more than one host needs — quota resolution, the monthly reset, the tier map, the Microsoft Graph directory client and both Entra sync services. References Data + Domain; no ASP.NET Core, which is what lets the isolated Functions worker use it
FoundryGate.FunctionsTimer-triggered Azure Functions (monthly reset, usage reconciliation, nightly Entra directory sync)
FoundryGate.WebBlazor WASM frontend (MudBlazor), Azure Static Web Apps — references Domain only
FoundryGate.DatabaseSQL Server project (.sqlproj) — builds the dacpac for CI/CD
FoundryGate.Clifoundrygate dotnet tool — local setup, db deploy, db compare, seed
FoundryGate.Tests.PredeploymentxUnit + SQLite in-memory + convention tests; gates every PR
FoundryGate.Tests.PostdeploymentxUnit over HTTP against a deployed environment — today the /api/v1 401/403/200 auth contract (#102); Playwright scenarios, desktop + mobile, land with #139