diff --git a/.claude/commands b/.claude/commands
index 62fec7ec..af1b8468 160000
--- a/.claude/commands
+++ b/.claude/commands
@@ -1 +1 @@
-Subproject commit 62fec7ecfa540cb64a2011324c308545a9a47c99
+Subproject commit af1b8468c72d9101c2400078f6bd839ef3b3904e
diff --git a/.github/scripts/Request-BuildNumber.ps1 b/.github/scripts/Request-BuildNumber.ps1
new file mode 100644
index 00000000..76f2803e
--- /dev/null
+++ b/.github/scripts/Request-BuildNumber.ps1
@@ -0,0 +1,102 @@
+<#
+.SYNOPSIS
+ Reserves a monotone build number from a counter blob (ETag compare-and-swap).
+
+.DESCRIPTION
+ Single source of build numbers for every component that ships from this repo
+ (agent, backend, MCP). Each component owns one counter blob holding a plain
+ integer; this script reads it, writes value+1 back under If-Match, and returns
+ the reserved number.
+
+ Reserve-BEFORE-build: two concurrent builds can never mint the same number.
+ A failed build burns a number, which is harmless -- uniqueness matters,
+ density does not.
+
+ The blob is public-read; only the write needs the container SAS.
+
+ Why a counter and not the published version manifest: manifests are written
+ on publish, not on build (the agent writes version.json only for stable
+ releases), so two dev builds in a row would derive the same number and
+ overwrite each other's versioned artifact.
+
+.PARAMETER CounterUrl
+ Full URL of the counter blob, without SAS.
+
+.PARAMETER SasToken
+ Container SAS with create+write permission. A leading '?' is tolerated.
+
+.PARAMETER Override
+ Explicit build number instead of counter+1. If it is not greater than the
+ current counter the blob is left untouched (re-build of a number that was
+ already reserved). 0 = no override.
+
+.PARAMETER MaxAttempts
+ CAS retries before giving up.
+
+.OUTPUTS
+ [int] the reserved build number. Progress goes to the host, not the pipeline.
+
+.EXAMPLE
+ $n = ./Request-BuildNumber.ps1 -CounterUrl $url -SasToken $sas
+#>
+[CmdletBinding()]
+param(
+ [Parameter(Mandatory = $true)][string]$CounterUrl,
+ [Parameter(Mandatory = $true)][string]$SasToken,
+ [int]$Override = 0,
+ [int]$MaxAttempts = 5
+)
+
+$ErrorActionPreference = 'Stop'
+
+# Tolerate both secret formats ("sp=cw&..." and "?sp=cw&...").
+$writeUrl = $CounterUrl + '?' + $SasToken.TrimStart('?')
+
+function Get-HttpStatus {
+ param($ErrorRecord)
+ if ($ErrorRecord.Exception.PSObject.Properties['Response'] -and $ErrorRecord.Exception.Response) {
+ return [int]$ErrorRecord.Exception.Response.StatusCode
+ }
+ return 0
+}
+
+function Read-Counter {
+ try {
+ $resp = Invoke-WebRequest -Uri $CounterUrl -Method Get -UseBasicParsing
+ } catch {
+ if ((Get-HttpStatus $_) -eq 404) {
+ throw "Counter blob not found at $CounterUrl. Seed it once with the last used build number: PUT the plain integer with 'x-ms-blob-type: BlockBlob' and 'If-None-Match: *'. See docs/versioning.md."
+ }
+ throw
+ }
+ $raw = if ($resp.Content -is [byte[]]) { [System.Text.Encoding]::UTF8.GetString($resp.Content) } else { [string]$resp.Content }
+ # If-Match requires the quoted ETag form; PowerShell strips the quotes on read.
+ $etag = '"' + ([string]($resp.Headers['ETag'] | Select-Object -First 1)).Trim('"') + '"'
+ return [pscustomobject]@{ Value = [int]$raw.Trim(); ETag = $etag }
+}
+
+for ($attempt = 1; $attempt -le $MaxAttempts; $attempt++) {
+ $counter = Read-Counter
+
+ if ($Override -gt 0 -and $Override -le $counter.Value) {
+ Write-Host " Build number $Override <= counter $($counter.Value) -- already reserved, counter blob unchanged"
+ return $Override
+ }
+ $target = if ($Override -gt 0) { $Override } else { $counter.Value + 1 }
+
+ try {
+ $headers = @{ 'x-ms-blob-type' = 'BlockBlob'; 'If-Match' = $counter.ETag }
+ Invoke-RestMethod -Uri $writeUrl -Method Put -Headers $headers -Body "$target" -ContentType 'text/plain' | Out-Null
+ Write-Host " Reserved build number $target (CAS write ok, attempt $attempt)"
+ return $target
+ } catch {
+ if ((Get-HttpStatus $_) -eq 412) {
+ Write-Host " CAS conflict on attempt $attempt (another build reserved concurrently) -- retrying"
+ Start-Sleep -Seconds ($attempt * 2)
+ } else {
+ throw
+ }
+ }
+}
+
+throw "Could not reserve a build number after $MaxAttempts CAS attempts"
diff --git a/.github/workflows/build-agent.yml b/.github/workflows/build-agent.yml
index ddb7853a..1fc23aab 100644
--- a/.github/workflows/build-agent.yml
+++ b/.github/workflows/build-agent.yml
@@ -64,70 +64,19 @@ jobs:
with:
dotnet-version: '10.0.x'
- # Build numbers come from the shared counter blob (single source of truth for CI
- # AND local build.ps1). Reserve-before-build with ETag-CAS: concurrent builds can
- # never mint the same number; a failed build burns a number, which is harmless.
- # The blob is public-read (like the rest of the container); writes need the SAS.
+ # Build numbers come from the shared counter blob -- one counter and one
+ # implementation for CI and the local build (.github/scripts/Request-BuildNumber.ps1).
+ # Reserve-before-build with ETag-CAS: concurrent builds can never mint the same
+ # number; a failed build burns a number, which is harmless.
- name: Reserve build number (counter blob CAS)
id: version
shell: pwsh
run: |
- $counterBlob = "buildcounter-v$env:LINE_MAJOR.txt"
- $counterUrl = "https://autopilotmonitoreu.blob.core.windows.net/agent/$counterBlob"
- # Tolerate both secret formats ("sp=cw&..." and "?sp=cw&...") -- the URL below adds the '?'.
- $sasToken = "${{ secrets.AZURE_BLOB_AGENT_SAS_TOKEN }}".TrimStart('?')
- $override = "${{ inputs.build_number }}"
+ $override = 0
+ if ('${{ inputs.build_number }}' -ne '') { $override = [int]'${{ inputs.build_number }}' }
- function Get-Counter {
- try {
- $resp = Invoke-WebRequest -Uri $counterUrl -Method Get
- $raw = if ($resp.Content -is [byte[]]) { [System.Text.Encoding]::UTF8.GetString($resp.Content) } else { [string]$resp.Content }
- # If-Match requires the quoted ETag form; pwsh strips the quotes on read.
- $etag = '"' + ([string]($resp.Headers['ETag'] | Select-Object -First 1)).Trim('"') + '"'
- return @{ Value = [int]$raw.Trim(); ETag = $etag }
- } catch {
- $status = if ($_.Exception.Response) { [int]$_.Exception.Response.StatusCode } else { 0 }
- if ($status -eq 404) {
- Write-Error "Counter blob '$counterBlob' not found. Seed it once with the last used build number (see docs/agent/build-counter-blob.md) -- do NOT seed from the repo's buildcounter.txt, it is a stale offline fallback."
- }
- throw
- }
- }
-
- $buildNumber = $null
- for ($attempt = 1; $attempt -le 5; $attempt++) {
- $counter = Get-Counter
- if ($override -ne '') {
- $target = [int]$override
- if ($target -le $counter.Value) {
- # Re-build of an already-reserved number: counter stays monotonic, no write.
- Write-Host "Manual build number $target <= counter $($counter.Value) -- counter blob unchanged"
- $buildNumber = $target
- break
- }
- Write-Host "Manual build number $target > counter $($counter.Value) -- advancing counter"
- } else {
- $target = $counter.Value + 1
- }
- try {
- $headers = @{ 'x-ms-blob-type' = 'BlockBlob'; 'If-Match' = $counter.ETag }
- Invoke-RestMethod -Uri "$counterUrl`?$sasToken" -Method Put -Headers $headers -Body "$target" -ContentType 'text/plain' | Out-Null
- $buildNumber = $target
- Write-Host "Reserved build number ${target} (CAS write ok, attempt $attempt)"
- break
- } catch {
- $status = if ($_.Exception.Response) { [int]$_.Exception.Response.StatusCode } else { 0 }
- if ($status -eq 412) {
- Write-Host "CAS conflict on attempt $attempt (another build reserved concurrently) -- retrying"
- Start-Sleep -Seconds ($attempt * 2)
- } else {
- throw
- }
- }
- }
- if ($null -eq $buildNumber) {
- Write-Error "Could not reserve a build number after 5 CAS attempts"
- }
+ $counterUrl = "https://autopilotmonitoreu.blob.core.windows.net/agent/buildcounter-v$($env:LINE_MAJOR).txt"
+ $buildNumber = & ./.github/scripts/Request-BuildNumber.ps1 -CounterUrl $counterUrl -SasToken '${{ secrets.AZURE_BLOB_AGENT_SAS_TOKEN }}' -Override $override
$csproj = Get-Content $env:CSPROJ_PATH -Raw
if ($csproj -match '(\d+\.\d+)') {
@@ -228,8 +177,13 @@ jobs:
Write-Warning "Could not parse bootstrap `$ScriptVersion from $env:BOOTSTRAP_SOURCE"
}
- # Enrich the per-line manifest emitted by MSBuild (version-v2.json, read above)
+ # Enrich the per-line manifest emitted by MSBuild (version-v2.json, read above).
+ # commit/buildUtc are the same two fields the backend and MCP manifests carry —
+ # additive, and the bootstrap reads this file by field name, so older installers
+ # ignore them. sha256/bootstrapVersion stay: those are the customer contract.
$manifest | Add-Member -NotePropertyName "sha256" -NotePropertyValue $sha256Hash -Force
+ $manifest | Add-Member -NotePropertyName "commit" -NotePropertyValue '${{ github.sha }}'.Substring(0, 7) -Force
+ $manifest | Add-Member -NotePropertyName "buildUtc" -NotePropertyValue ([DateTime]::UtcNow.ToString('o')) -Force
if ($bootstrapVersion) {
$manifest | Add-Member -NotePropertyName "bootstrapVersion" -NotePropertyValue $bootstrapVersion -Force
}
diff --git a/.github/workflows/deploy-backend.yml b/.github/workflows/deploy-backend.yml
index 7ae4b91e..ba5f8b1a 100644
--- a/.github/workflows/deploy-backend.yml
+++ b/.github/workflows/deploy-backend.yml
@@ -7,6 +7,12 @@ permissions:
id-token: write # OIDC federated login (secret-less)
contents: read
+env:
+ FUNCTION_APP: autopilotmonitor-api-eu
+ HEALTH_URL: https://autopilotmonitor-api-eu.azurewebsites.net/api/health
+ VERSIONS_CONTAINER: https://autopilotmonitoreu.blob.core.windows.net/versions
+ CSPROJ_PATH: src/Backend/AutopilotMonitor.Functions/AutopilotMonitor.Functions.csproj
+
jobs:
build-and-deploy:
runs-on: ubuntu-latest
@@ -27,17 +33,90 @@ jobs:
with:
dotnet-version: '10.0.x'
+ # Same counter mechanism as the agent line: reserve-before-build with ETag-CAS
+ # against a per-component counter blob. BuildTimestampUtc is pinned here rather
+ # than left to MSBuild's default so the published manifest and the value baked
+ # into the assembly (reported by /api/health) are the same instant.
+ - name: Reserve build number (counter blob CAS)
+ id: version
+ shell: pwsh
+ run: |
+ $buildNumber = & ./.github/scripts/Request-BuildNumber.ps1 -CounterUrl "${{ env.VERSIONS_CONTAINER }}/backend.txt" -SasToken '${{ secrets.AZURE_BLOB_VERSIONS_SAS_TOKEN }}'
+
+ $csproj = Get-Content ${{ env.CSPROJ_PATH }} -Raw
+ if ($csproj -notmatch '(\d+\.\d+)') {
+ throw "VersionPrefix not found in ${{ env.CSPROJ_PATH }}"
+ }
+ $version = "$($Matches[1]).$buildNumber"
+ $buildUtc = [DateTime]::UtcNow.ToString('o')
+ Write-Host "Backend version: $version (built $buildUtc)"
+
+ "build_number=$buildNumber" >> $env:GITHUB_OUTPUT
+ "version=$version" >> $env:GITHUB_OUTPUT
+ "build_utc=$buildUtc" >> $env:GITHUB_OUTPUT
+
- name: Restore dependencies
- run: dotnet restore src/Backend/AutopilotMonitor.Functions/AutopilotMonitor.Functions.csproj
+ run: dotnet restore ${{ env.CSPROJ_PATH }}
- name: Build
- run: dotnet build src/Backend/AutopilotMonitor.Functions/AutopilotMonitor.Functions.csproj --configuration Release --no-restore -p:SourceRevisionId=${{ github.sha }}
+ run: dotnet build ${{ env.CSPROJ_PATH }} --configuration Release --no-restore -p:SourceRevisionId=${{ github.sha }} -p:BuildNumber=${{ steps.version.outputs.build_number }} -p:BuildTimestampUtc=${{ steps.version.outputs.build_utc }}
- name: Publish
- run: dotnet publish src/Backend/AutopilotMonitor.Functions/AutopilotMonitor.Functions.csproj --configuration Release --output ./publish --no-build -p:SourceRevisionId=${{ github.sha }}
+ run: dotnet publish ${{ env.CSPROJ_PATH }} --configuration Release --output ./publish --no-build -p:SourceRevisionId=${{ github.sha }} -p:BuildNumber=${{ steps.version.outputs.build_number }} -p:BuildTimestampUtc=${{ steps.version.outputs.build_utc }}
- name: Deploy to Azure Functions
uses: Azure/functions-action@v1
with:
- app-name: autopilotmonitor-api-eu
+ app-name: ${{ env.FUNCTION_APP }}
package: ./publish
+
+ # A green deploy step only means the package was accepted. This gate makes the
+ # workflow assert what is actually being served, so the manifest published below
+ # can be trusted as "this is live" rather than "this was uploaded".
+ # /api/health is IP rate-limited at 30/min; one poll per 10s stays well under.
+ - name: Verify deployed build is live
+ shell: pwsh
+ run: |
+ $expectedCommit = '${{ github.sha }}'.Substring(0, 7)
+ $expectedVersion = '${{ steps.version.outputs.version }}'
+ $deadline = [DateTime]::UtcNow.AddMinutes(3)
+ $attempt = 0
+
+ while ([DateTime]::UtcNow -lt $deadline) {
+ $attempt++
+ try {
+ $health = Invoke-RestMethod -Uri '${{ env.HEALTH_URL }}' -Method Get -TimeoutSec 30
+ if ($health.commitHash -eq $expectedCommit -and $health.version -eq $expectedVersion) {
+ Write-Host "Live after $attempt attempt(s): $($health.version) @ $($health.commitHash), built $($health.buildUtc)"
+ exit 0
+ }
+ Write-Host "Attempt ${attempt}: serving $($health.version) @ $($health.commitHash) -- waiting for $expectedVersion @ $expectedCommit"
+ } catch {
+ Write-Host "Attempt ${attempt}: /api/health not answering yet -- $($_.Exception.Message)"
+ }
+ Start-Sleep -Seconds 10
+ }
+
+ throw "Backend did not report $expectedVersion @ $expectedCommit within 3 minutes -- deployment not verified, version manifest NOT published"
+
+ # The manifest is the cross-component record of what is deployed where. It is
+ # written last and only on a verified deploy, so a difference between the
+ # manifest and /api/health means the running app drifted after the fact
+ # (rollback, stale instance) rather than a half-finished pipeline.
+ - name: Publish version manifest
+ shell: pwsh
+ run: |
+ $manifest = [ordered]@{
+ component = 'backend'
+ version = '${{ steps.version.outputs.version }}'
+ commit = '${{ github.sha }}'.Substring(0, 7)
+ buildUtc = '${{ steps.version.outputs.build_utc }}'
+ deployedUtc = [DateTime]::UtcNow.ToString('o')
+ runId = '${{ github.run_id }}'
+ } | ConvertTo-Json
+
+ $sas = '${{ secrets.AZURE_BLOB_VERSIONS_SAS_TOKEN }}'.TrimStart('?')
+ $url = "${{ env.VERSIONS_CONTAINER }}/backend.json?$sas"
+ Invoke-RestMethod -Uri $url -Method Put -Headers @{ 'x-ms-blob-type' = 'BlockBlob' } -Body $manifest -ContentType 'application/json' | Out-Null
+ Write-Host "Published versions/backend.json"
+ Write-Host $manifest
diff --git a/.github/workflows/deploy-mcp.yml b/.github/workflows/deploy-mcp.yml
index 300f4e37..9b350d9d 100644
--- a/.github/workflows/deploy-mcp.yml
+++ b/.github/workflows/deploy-mcp.yml
@@ -12,6 +12,9 @@ env:
CONTAINER_APP_NAME: autopilotmonitor-mcp
RESOURCE_GROUP: ${{ secrets.AZURE_MCP_RESOURCE_GROUP }}
IMAGE_TAG: ${{ github.sha }}
+ HEALTH_URL: https://mcp.autopilotmonitor.com/health
+ VERSIONS_CONTAINER: https://autopilotmonitoreu.blob.core.windows.net/versions
+ PACKAGE_JSON: src/McpServer/autopilot-monitor-mcp/package.json
jobs:
build-and-deploy:
@@ -44,6 +47,30 @@ jobs:
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
+ # Same counter mechanism as agent and backend: reserve-before-build with
+ # ETag-CAS against the component's own counter blob. major.minor stays curated
+ # in package.json; only the build part is minted here.
+ - name: Reserve build number (counter blob CAS)
+ id: version
+ shell: pwsh
+ run: |
+ $buildNumber = & ./.github/scripts/Request-BuildNumber.ps1 -CounterUrl "${{ env.VERSIONS_CONTAINER }}/mcp.txt" -SasToken '${{ secrets.AZURE_BLOB_VERSIONS_SAS_TOKEN }}'
+
+ $pkgVersion = (Get-Content ${{ env.PACKAGE_JSON }} -Raw | ConvertFrom-Json).version
+ $prefix = ($pkgVersion -split '\.')[0..1] -join '.'
+ $version = "$prefix.$buildNumber"
+ $buildUtc = [DateTime]::UtcNow.ToString('o')
+ Write-Host "MCP version: $version (built $buildUtc)"
+
+ # 7 chars, matching what the backend's BackendBuildInfo reports — one commit
+ # shape across all components so the values can be compared as-is.
+ $shortSha = '${{ github.sha }}'.Substring(0, 7)
+
+ "build_number=$buildNumber" >> $env:GITHUB_OUTPUT
+ "version=$version" >> $env:GITHUB_OUTPUT
+ "build_utc=$buildUtc" >> $env:GITHUB_OUTPUT
+ "short_sha=$shortSha" >> $env:GITHUB_OUTPUT
+
- name: Login to ACR
run: az acr login --name ${{ env.ACR_NAME }}
@@ -52,6 +79,9 @@ jobs:
docker build \
-f src/McpServer/autopilot-monitor-mcp/Dockerfile \
--build-arg DOCS_COMMIT=${{ steps.docs.outputs.sha }} \
+ --build-arg BUILD_NUMBER=${{ steps.version.outputs.build_number }} \
+ --build-arg BUILD_COMMIT=${{ steps.version.outputs.short_sha }} \
+ --build-arg BUILD_UTC=${{ steps.version.outputs.build_utc }} \
-t ${{ env.ACR_NAME }}.azurecr.io/${{ env.CONTAINER_APP_NAME }}:${{ env.IMAGE_TAG }} \
-t ${{ env.ACR_NAME }}.azurecr.io/${{ env.CONTAINER_APP_NAME }}:latest \
.
@@ -63,3 +93,54 @@ jobs:
--name ${{ env.CONTAINER_APP_NAME }} \
--resource-group ${{ env.RESOURCE_GROUP }} \
--image ${{ env.ACR_NAME }}.azurecr.io/${{ env.CONTAINER_APP_NAME }}:${{ env.IMAGE_TAG }}
+
+ # `az containerapp update` returns once the revision is provisioned, not once
+ # it serves traffic — and with minReplicas=0 the first request also pays a cold
+ # start. Polling /health until it reports THIS build is what makes the manifest
+ # below mean "live"; a full 5 minutes because the cold start alone is ~25s.
+ - name: Verify deployed build is live
+ shell: pwsh
+ run: |
+ $expectedCommit = '${{ steps.version.outputs.short_sha }}'
+ $expectedVersion = '${{ steps.version.outputs.version }}'
+ $deadline = [DateTime]::UtcNow.AddMinutes(5)
+ $attempt = 0
+
+ while ([DateTime]::UtcNow -lt $deadline) {
+ $attempt++
+ try {
+ $health = Invoke-RestMethod -Uri '${{ env.HEALTH_URL }}' -Method Get -TimeoutSec 60
+ if ($health.commit -eq $expectedCommit -and $health.version -eq $expectedVersion) {
+ Write-Host "Live after $attempt attempt(s): $($health.version) @ $($health.commit), docs $($health.docs.commit) ($($health.docs.chunks) chunks)"
+ exit 0
+ }
+ Write-Host "Attempt ${attempt}: serving $($health.version) @ $($health.commit) -- waiting for $expectedVersion @ $expectedCommit"
+ } catch {
+ Write-Host "Attempt ${attempt}: /health not answering yet -- $($_.Exception.Message)"
+ }
+ Start-Sleep -Seconds 15
+ }
+
+ throw "MCP did not report $expectedVersion @ $expectedCommit within 5 minutes -- deployment not verified, version manifest NOT published"
+
+ # Cross-component record of what is deployed where; written last and only on a
+ # verified deploy. docsCommit rides along because for this component "which
+ # build" and "which documentation" are two separate questions.
+ - name: Publish version manifest
+ shell: pwsh
+ run: |
+ $manifest = [ordered]@{
+ component = 'mcp'
+ version = '${{ steps.version.outputs.version }}'
+ commit = '${{ steps.version.outputs.short_sha }}'
+ buildUtc = '${{ steps.version.outputs.build_utc }}'
+ deployedUtc = [DateTime]::UtcNow.ToString('o')
+ runId = '${{ github.run_id }}'
+ docsCommit = '${{ steps.docs.outputs.sha }}'.Substring(0, 7)
+ } | ConvertTo-Json
+
+ $sas = '${{ secrets.AZURE_BLOB_VERSIONS_SAS_TOKEN }}'.TrimStart('?')
+ $url = "${{ env.VERSIONS_CONTAINER }}/mcp.json?$sas"
+ Invoke-RestMethod -Uri $url -Method Put -Headers @{ 'x-ms-blob-type' = 'BlockBlob' } -Body $manifest -ContentType 'application/json' | Out-Null
+ Write-Host "Published versions/mcp.json"
+ Write-Host $manifest
diff --git a/docs/agent/build-counter-blob.md b/docs/agent/build-counter-blob.md
index 13a0a2e8..5eee8eda 100644
--- a/docs/agent/build-counter-blob.md
+++ b/docs/agent/build-counter-blob.md
@@ -22,26 +22,21 @@ attestation on top.
# Schema
-- **Counter blob**: `https://autopilotmonitor.blob.core.windows.net/agent/buildcounter-v{LINE}.txt`
- (per release line, analogous to `version-v{LINE}.json`). Content: the **last used**
- build number as plain text. Public-read like the rest of the container (the number
- is visible in `version-v{LINE}.json` anyway); writes require the container SAS.
-- **Protocol — reserve before build, ETag-CAS**:
- 1. Anonymous `GET` the blob, capture value `N` and the `ETag`.
- 2. `PUT N+1` with `If-Match: ` (quoted form — pwsh strips the quotes on read,
- they must be re-added or the request is rejected client-side).
- 3. `412 Precondition Failed` → another build reserved concurrently → re-read, retry
- (5 attempts, linear backoff).
- 4. Build with `BUILD_NUMBER=N+1` in the environment. The csproj honors
- `BUILD_NUMBER` and then skips its `PersistBuildCounter` target, so the repo file
- is not touched.
-- **Failed builds burn a number.** That is intentional: uniqueness matters, density
- does not. Reserve-before-build is what makes concurrent minting impossible.
+The reserve-before-build CAS protocol itself is shared with the backend and the MCP
+server and is documented once in [Version Contract](../versioning.md); the single
+implementation is `.github/scripts/Request-BuildNumber.ps1`. Agent-specific:
+
+- **Counter blob**: `https://autopilotmonitoreu.blob.core.windows.net/agent/buildcounter-v{LINE}.txt`
+ — one per release line, analogous to `version-v{LINE}.json`, and kept in the `agent`
+ container next to the artifacts it numbers rather than in `versions/`. Content: the
+ **last used** build number as plain text. Public-read like the rest of the container
+ (the number is visible in `version-v{LINE}.json` anyway); writes require the SAS.
+- The reserved number reaches the build as `BUILD_NUMBER` in the environment. The
+ csproj honors it and then skips its `PersistBuildCounter` target, so the repo file
+ is not touched.
- **CI manual override** (`build_number` input): numbers `<=` counter are re-builds
and leave the blob unchanged; numbers `>` counter advance it (monotonic guard).
-- **Seeding** (one-time per line): `PUT ` with `If-None-Match: *`
- so an existing counter can never be clobbered. V2 was seeded with `1356` on
- 2026-07-17.
+- V2 was seeded with `1356` on 2026-07-17.
# Consequences
@@ -62,7 +57,9 @@ attestation on top.
# Citations
+- [Version Contract](../versioning.md) — the shared CAS protocol and manifest format
- `.github/workflows/build-agent.yml` — "Reserve build number (counter blob CAS)" step
-- `scripts/Deployment/build.ps1` (gitignored, contains SAS) — `Request-BuildNumber`
+- `.github/scripts/Request-BuildNumber.ps1` — the shared implementation
+- `scripts/Deployment/build.ps1` (gitignored, contains SAS) — local build path
- `src/Agent/AutopilotMonitor.Agent.V2/AutopilotMonitor.Agent.V2.csproj` —
`BUILD_NUMBER` property + `PersistBuildCounter` target
diff --git a/docs/index.md b/docs/index.md
index 8e270391..126e63a4 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -12,6 +12,7 @@ https://docs.autopilotmonitor.com (separate repository).
# Architecture
* [Architecture Guide](architecture.md) - High-level architecture overview and solution layout for contributors.
+* [Version Contract](versioning.md) - One version shape for agent, backend and MCP: the ETag-CAS counter blob that mints build numbers, and the manifests published only after a deploy is verified live.
# Agent
diff --git a/docs/log.md b/docs/log.md
index 645ac9b5..459a1f4a 100644
--- a/docs/log.md
+++ b/docs/log.md
@@ -2,6 +2,8 @@
## 2026-07-22
+* **Creation**: Added `versioning.md` — agent, backend and MCP now share one version shape (`..` + 7-char commit + build timestamp) and one implementation of the build-number reservation (`.github/scripts/Request-BuildNumber.ps1`, previously copy-pasted between `build-agent.yml` and `build.ps1`). The backend had been pinned at a hand-written `1.0.0` since inception and now starts at `1.5.1000`; the MCP server's `package.json` version was bumped by hand and now starts at `1.5.300`, and it reports its own commit on `/health` instead of only the documentation bundle's. Both deploy workflows poll their health endpoint until it reports the build just deployed and publish `versions/.json` only then, so a manifest that disagrees with the live endpoint means the instance drifted rather than the pipeline failing halfway. `agent/build-counter-blob.md` keeps the agent-specific parts and now points at the shared protocol; its counter URL was still the pre-cutover storage account.
+
* **Creation**: Added `rules/gather-rule-guardrails.md` — the gather-rule allowlists are enforced only on the agent (the backend validates no `collectorType`, `target`, or `parameters`), so a collector that forgets to call its guard *is* the vulnerability. Written because two had: `LogParserCollector` read any path in `rule.Target`, bypassing even the `C:\Users` hard block that every other file collector honours, and `EventLogCollector` read any channel including `Security` — while the customer-facing documentation already promised both were allowlisted. Both now guard, with `logparser` checking the directory for wildcard targets (`Path.GetFullPath` throws on `*`) and re-checking each resolved path. Added an event-log channel allowlist (`eventLogChannels`) plus hard-blocked channels held in C# rather than JSON, so a parse error cannot lift them. Also fixed the command timeout, which was inert: `ReadToEnd()` blocks until the pipe closes, so `WaitForExit(30000)` was only reached after the process had already exited and a hung command pinned its worker forever — now read asynchronously and killed at the timeout.
* **Update**: Added `.github/workflows/ci.yml` — the full suite (backend, agent V2 Core, DecisionCore, SummaryDialog, web, MCP) now runs on every pull request and every push to main, plus a job that fails if the generated guardrail files drift from `rules/guardrails.json`. Previously **no** workflow ran tests on a PR: the deploy workflows build without testing, and `build-agent.yml` tested only `DecisionCore.Tests` on manual dispatch — while `trust/security-faq.md` already told customers that automated tests run in CI. Added CodeQL (C# and TypeScript) and Dependabot (nuget, npm ×2, actions). Marked `OfficeBinaryWatcherTests` `[Collection("SerialThreading")]`: its bounded re-probe races the shared ThreadPool and failed once under full-suite load.
diff --git a/docs/versioning.md b/docs/versioning.md
new file mode 100644
index 00000000..e82c2594
--- /dev/null
+++ b/docs/versioning.md
@@ -0,0 +1,108 @@
+---
+type: Concept
+title: Version Contract — Agent, Backend, MCP
+description: One version shape for all three shipping components, the ETag-CAS counter blob that mints build numbers, and the published manifests that make "what is deployed" checkable from outside.
+resource: /.github/scripts/Request-BuildNumber.ps1
+tags:
+ - build
+ - ci
+ - release
+ - versioning
+timestamp: 2026-07-22T00:00:00+02:00
+---
+
+# Version Contract
+
+Three components ship from this repository — the agent, the Functions backend and the
+MCP server. They share one version shape, one way of minting build numbers, and one
+manifest format, so that a version string means the same thing whoever emits it.
+
+```
+version = .. major.minor curated by hand, build minted per build
+commit = 7-char short SHA of this repo
+buildUtc = ISO-8601 build moment
+```
+
+| Component | major.minor from | Counter blob | Reported by |
+|---|---|---|---|
+| Agent V2 | `VersionPrefix` in `AutopilotMonitor.Agent.V2.csproj` | `agent/buildcounter-v2.txt` | `agent/version.json` |
+| Backend | `VersionPrefix` in `AutopilotMonitor.Functions.csproj` | `versions/backend.txt` | `GET /api/health` |
+| MCP | `version` in `package.json` | `versions/mcp.txt` | `GET /health`, MCP handshake |
+
+# Schema
+
+## Build numbers — reserve before build, ETag-CAS
+
+`.github/scripts/Request-BuildNumber.ps1` is the only implementation; the CI
+workflows and the local `build.ps1` all call it.
+
+1. Anonymous `GET` on the counter blob → value `N` and `ETag`. The blob holds the
+ **last used** number as plain text and is public-read; only the write needs the
+ container SAS.
+2. `PUT N+1` with `If-Match: `. The quoted ETag form is required — PowerShell
+ strips the quotes on read, and an unquoted `If-Match` is rejected client-side.
+3. `412 Precondition Failed` → a concurrent build reserved first → re-read and retry
+ (5 attempts, linear backoff).
+4. Build with the reserved number.
+
+Reserving *before* the build is what makes concurrent minting impossible. **A failed
+build burns a number** — deliberate: uniqueness matters, density does not.
+
+Why a counter and not the published manifest: manifests are written on publish, not on
+build. The agent writes `version.json` only for stable releases, so deriving the next
+number from it would hand two consecutive dev builds the same number and let the second
+overwrite the first's artifact. A state-free scheme such as `git rev-list --count HEAD`
+fails for the same reason — two builds of one commit collide.
+
+**Seeding** (once per component): `PUT ` with `If-None-Match: *`, so
+an existing counter can never be clobbered. Agent V2 was seeded with `1356` on
+2026-07-17; `versions/backend.txt` with `999` and `versions/mcp.txt` with `299` on
+2026-07-22.
+
+## Local builds
+
+No counter is reached, the build number stays `0`, and the commit is empty or
+`unknown`. A `1.5.0` with no commit hash on `/api/health` is therefore a developer
+machine, not a release — the fallback is the signal, not a defect. The agent is the
+exception: `build.ps1` produces uploadable artifacts and reserves a real number unless
+`-SkipUpload` is passed.
+
+## Manifests
+
+`versions/backend.json` and `versions/mcp.json` (public-read) record what is deployed:
+
+```json
+{ "component": "backend", "version": "1.5.1000", "commit": "abc1234",
+ "buildUtc": "…", "deployedUtc": "…", "runId": "…" }
+```
+
+The MCP manifest adds `docsCommit`, because for that component "which build" and "which
+documentation" are separate questions.
+
+Each is written **last and only after the deploy workflow has polled the component's
+health endpoint until it reports that exact version and commit**. A green deploy step
+alone means the package was accepted, not that it is being served. Consequently a
+manifest that disagrees with the live endpoint means the running instance drifted after
+the fact (rollback, stale revision) rather than a half-finished pipeline.
+
+The agent keeps its own manifest at `agent/version.json` — the bootstrap derives that
+URL from the download URL and verifies the ZIP against its `sha256`, so it cannot move.
+It carries the same `commit` and `buildUtc` fields; `sha256` and `bootstrapVersion`
+remain, being the customer-facing integrity contract.
+
+# Consequences
+
+- Bumping `major.minor` is a human decision, made in the component's own file
+ (`VersionPrefix`, or `version` in `package.json`). Nothing bumps it automatically.
+- A new component joins by seeding a counter blob and calling the same script; no new
+ mechanism.
+- The counter blobs and manifests live in the `versions` container on
+ `autopilotmonitoreu` (writes via `AZURE_BLOB_VERSIONS_SAS_TOKEN`). The agent's
+ counter stays in the `agent` container next to the artifacts it numbers.
+
+# Citations
+
+- `.github/scripts/Request-BuildNumber.ps1` — the CAS protocol
+- `.github/workflows/deploy-backend.yml`, `deploy-mcp.yml` — reserve, verify, publish
+- `.github/workflows/build-agent.yml`, `scripts/Deployment/build.ps1` — agent line
+- [Build Counter Blob](agent/build-counter-blob.md) — agent-specific consequences
diff --git a/src/Backend/AutopilotMonitor.Functions/AutopilotMonitor.Functions.csproj b/src/Backend/AutopilotMonitor.Functions/AutopilotMonitor.Functions.csproj
index 61a07e8f..a5c58f64 100644
--- a/src/Backend/AutopilotMonitor.Functions/AutopilotMonitor.Functions.csproj
+++ b/src/Backend/AutopilotMonitor.Functions/AutopilotMonitor.Functions.csproj
@@ -12,8 +12,16 @@
BuildTimestampUtc is baked into the assembly as AssemblyMetadata so /api/health
reports the actual build moment — not the file mtime, which Azure Functions
resets to the zip-extract time on every cold start. CI may override via
- -p:BuildTimestampUtc= to force a specific timestamp. -->
- 1.0.0
+ -p:BuildTimestampUtc= to force a specific timestamp.
+
+ VersionPrefix is the curated part (bump by hand for a release); BuildNumber is
+ reserved per build from the counter blob versions/backend.txt and passed in as
+ -p:BuildNumber= by deploy-backend.yml — same mechanism as the agent line.
+ A local build has no counter and stays at x.y.0 with an empty commit hash,
+ which is how /api/health tells a developer machine from a CI artifact. -->
+ 1.5
+ 0
+ $(VersionPrefix).$(BuildNumber)
true
true
$([System.DateTime]::UtcNow.ToString('o'))
diff --git a/src/McpServer/autopilot-monitor-mcp/Dockerfile b/src/McpServer/autopilot-monitor-mcp/Dockerfile
index 512870e4..00319b08 100644
--- a/src/McpServer/autopilot-monitor-mcp/Dockerfile
+++ b/src/McpServer/autopilot-monitor-mcp/Dockerfile
@@ -89,6 +89,16 @@ COPY --from=build /app/docs/ ./docs/
ARG DOCS_COMMIT=unknown
ENV DOCS_COMMIT=${DOCS_COMMIT}
+# Build identity of the server itself (deploy-mcp.yml supplies all three).
+# BUILD_NUMBER completes the version as ..; left unset in a
+# local `docker build`, which keeps package.json's version and marks it as such.
+ARG BUILD_NUMBER=
+ARG BUILD_COMMIT=unknown
+ARG BUILD_UTC=unknown
+ENV BUILD_NUMBER=${BUILD_NUMBER}
+ENV BUILD_COMMIT=${BUILD_COMMIT}
+ENV BUILD_UTC=${BUILD_UTC}
+
ENV PORT=8080
ENV RULES_DIR=/app/rules
ENV DOCS_DIR=/app/docs
diff --git a/src/McpServer/autopilot-monitor-mcp/src/index.ts b/src/McpServer/autopilot-monitor-mcp/src/index.ts
index b47219e2..aacd6af2 100644
--- a/src/McpServer/autopilot-monitor-mcp/src/index.ts
+++ b/src/McpServer/autopilot-monitor-mcp/src/index.ts
@@ -23,7 +23,21 @@ import { API_BASE_URL } from './config.js';
const __dirname = dirname(fileURLToPath(import.meta.url));
const pkg = JSON.parse(readFileSync(resolve(__dirname, '..', 'package.json'), 'utf-8')) as { version: string };
-const SERVER_VERSION: string = pkg.version;
+
+// Version contract shared with the agent and the backend: ..,
+// where major.minor is curated in package.json and is reserved per build
+// from the counter blob versions/mcp.txt and baked in by deploy-mcp.yml. Without
+// the env var (any local run) the package.json version stands as-is, which is how
+// /health and the MCP handshake tell a workstation from a deployed image.
+const BUILD_NUMBER = process.env.BUILD_NUMBER ?? '';
+const SERVER_VERSION: string = BUILD_NUMBER
+ ? `${pkg.version.split('.').slice(0, 2).join('.')}.${BUILD_NUMBER}`
+ : pkg.version;
+
+// Commit of THIS repo the image was built from. Counterpart to DOCS_COMMIT below:
+// that one tracks the documentation bundle, this one the server itself.
+const BUILD_COMMIT = process.env.BUILD_COMMIT ?? 'unknown';
+const BUILD_UTC = process.env.BUILD_UTC ?? 'unknown';
/**
* Boot stopwatch. minReplicas=0 means every idle period ends in a cold start a user
@@ -299,6 +313,9 @@ app.use(createOAuthRouter());
// The version is the same value already advertised in the MCP handshake
// (createMcpServer → { name, version }), so surfacing it here leaks nothing
// new; it lets the backend health dashboard show the deployed MCP build.
+// commit/buildUtc mirror what the backend's own /api/health has always
+// reported (also anonymous): an opaque SHA carries no repository content and
+// turns "which build is this?" into one request instead of an image lookup.
//
// The docs block exists for one reason: the documentation bundle lives in its own
// repo and is baked in at image build time, so editing the docs does NOT update a
@@ -310,6 +327,8 @@ app.get('/health', (_req, res) => {
res.json({
status: 'healthy',
version: SERVER_VERSION,
+ commit: BUILD_COMMIT,
+ buildUtc: BUILD_UTC,
docs: { commit: DOCS_COMMIT, chunks: docsIndex?.size ?? 0, sections: docSections(docsDocs) },
});
});