From 0e878664b08cdf7fb2d89d940bc2aa92cdc485f7 Mon Sep 17 00:00:00 2001 From: Luke Tornquist Date: Wed, 2 Sep 2026 12:23:16 -0400 Subject: [PATCH 01/13] ci: split CI into reusable workflows (#7168) ## Why The monolithic CI workflow is a frequent merge-conflict hotspot. Splitting cohesive domains into same-repository reusable workflows keeps one centrally filtered entry point while letting Rust, desktop, relay/PostgreSQL, client, and security CI evolve independently. ## What - Keep `ci.yml` as the only push/pull-request orchestrator with unchanged concurrency and path detection. - Move 18 execution jobs into five `workflow_call`-only domain workflows without changing their runners, steps, matrices, caches, artifacts, permissions, or timeouts. - Keep the relay artifact producer with desktop integration, the complete PostgreSQL lane, and relay E2E consumers. - Preserve all 12 existing required GitHub Actions contexts through lightweight top-level compatibility gates, so the repository ruleset does not need to change. - Update the Rust-cache contract to follow Unit Tests into `_ci-rust.yml`. ## Risk Assessment CI-only change with moderate workflow-orchestration risk. The main risks are reusable-workflow output propagation, skip behavior, and visible check naming; the old required names remain explicit top-level jobs, and the draft will stay open until an exact-head GitHub Actions run and independent review are complete. Generated with Codex --------- Signed-off-by: Luke Tornquist Signed-off-by: tornquist --- .github/workflows/_ci-clients.yml | 148 ++ .github/workflows/_ci-desktop-macos.yml | 110 ++ .github/workflows/_ci-desktop.yml | 244 +++ .github/workflows/_ci-relay.yml | 611 +++++++ .github/workflows/_ci-rust.yml | 219 +++ .github/workflows/_ci-security.yml | 42 + .github/workflows/ci.yml | 1401 +++-------------- scripts/test-ci-required-context-isolation.sh | 83 + .../test-rust-cache-contract-regressions.sh | 2 +- scripts/test-rust-cache-contract.sh | 6 +- 10 files changed, 1716 insertions(+), 1150 deletions(-) create mode 100644 .github/workflows/_ci-clients.yml create mode 100644 .github/workflows/_ci-desktop-macos.yml create mode 100644 .github/workflows/_ci-desktop.yml create mode 100644 .github/workflows/_ci-relay.yml create mode 100644 .github/workflows/_ci-rust.yml create mode 100644 .github/workflows/_ci-security.yml create mode 100755 scripts/test-ci-required-context-isolation.sh diff --git a/.github/workflows/_ci-clients.yml b/.github/workflows/_ci-clients.yml new file mode 100644 index 0000000000..af5861e5f1 --- /dev/null +++ b/.github/workflows/_ci-clients.yml @@ -0,0 +1,148 @@ +name: CI / Clients +on: + workflow_call: + inputs: + web: + required: true + type: boolean + mobile: + required: true + type: boolean + lane: + required: true + type: string + outputs: + web_result: + value: ${{ jobs.results.outputs.web_result }} + mobile_result: + value: ${{ jobs.results.outputs.mobile_result }} + +env: + CARGO_TERM_COLOR: always + BUZZ_TEST_POSTGRES_PASSWORD: buzz_dev + PLAYWRIGHT_BROWSERS_PATH: ${{ github.workspace }}/.cache/ms-playwright + +jobs: + web: + name: Web + runs-on: ubuntu-latest + timeout-minutes: 15 + if: inputs.lane == 'required' && (github.event_name == 'push' || inputs.web) + permissions: + contents: read + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + fetch-depth: 2 + - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 + - name: Get pnpm store directory + id: pnpm-cache + run: echo "STORE_PATH=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT" + - name: Restore pnpm store cache + uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5 + with: + path: ${{ steps.pnpm-cache.outputs.STORE_PATH }} + key: pnpm-${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }} + restore-keys: pnpm-${{ runner.os }}- + - name: Install dependencies + run: pnpm install --frozen-lockfile + - name: Web lint and format + run: just web-check + - name: Web build + run: just web-build + - name: Save pnpm store cache + if: github.event_name == 'push' + uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5 + with: + path: ${{ steps.pnpm-cache.outputs.STORE_PATH }} + key: pnpm-${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }} + + mobile: + name: Mobile + runs-on: ubuntu-latest + timeout-minutes: 30 + if: inputs.lane == 'required' && (github.event_name == 'push' || inputs.mobile) + permissions: + contents: read + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + fetch-depth: 2 + - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 + - name: Compute Hermit cache key + id: hermit-bin-hash + run: | + hash="$(find ./bin ! -type d | sort | xargs openssl sha256 | openssl sha256 -r | cut -d' ' -f1)" + echo "hash=$hash" >> "$GITHUB_OUTPUT" + - name: Restore Hermit package cache + id: hermit-cache + uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5 + with: + path: ~/.cache/hermit/pkg + key: ${{ runner.os }}-hermit-cache-${{ steps.hermit-bin-hash.outputs.hash }} + restore-keys: ${{ runner.os }}-hermit-cache- + - name: Prime Flutter SDK + run: flutter --version + - name: Save Hermit package cache + if: always() && steps.hermit-cache.outputs.cache-hit != 'true' + uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5 + continue-on-error: true + with: + path: ~/.cache/hermit/pkg + key: ${{ runner.os }}-hermit-cache-${{ steps.hermit-bin-hash.outputs.hash }} + - name: Restore pub cache + id: pub-cache + uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5 + with: + path: ~/.pub-cache + key: pub-${{ runner.os }}-${{ hashFiles('mobile/pubspec.lock') }} + restore-keys: pub-${{ runner.os }}- + - name: Install dependencies + run: cd mobile && flutter pub get + - name: Save pub cache + if: always() && steps.pub-cache.outputs.cache-hit != 'true' + uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5 + continue-on-error: true + with: + path: ~/.pub-cache + key: pub-${{ runner.os }}-${{ hashFiles('mobile/pubspec.lock') }} + - name: Format check + run: cd mobile && dart format --output=none --set-exit-if-changed . + - name: Analyze + run: cd mobile && flutter analyze + - name: Test + run: cd mobile && flutter test + - name: Build Android debug APK + run: just mobile-build-android + + mobile-swift: + name: Mobile Swift + runs-on: macos-latest + timeout-minutes: 30 + if: inputs.lane == 'mobile-swift' && inputs.mobile + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 + - name: Install Flutter dependencies + run: cd mobile && flutter pub get + - name: Build + run: swift build --package-path mobile/ios/BuzzPushKit + - name: Build release + run: swift build -c release --package-path mobile/ios/BuzzPushKit + - name: Test + run: swift test --package-path mobile/ios/BuzzPushKit + - name: Build complete unsigned iOS release + run: cd mobile && flutter build ios --release --no-codesign --no-pub + + results: + name: Results + if: ${{ always() }} + needs: [web, mobile, mobile-swift] + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: {} + outputs: + web_result: ${{ needs.web.result }} + mobile_result: ${{ needs.mobile.result }} + steps: + - run: echo "Captured client job results" diff --git a/.github/workflows/_ci-desktop-macos.yml b/.github/workflows/_ci-desktop-macos.yml new file mode 100644 index 0000000000..728f17f324 --- /dev/null +++ b/.github/workflows/_ci-desktop-macos.yml @@ -0,0 +1,110 @@ +name: CI / Desktop macOS +on: + workflow_call: + inputs: + rust: + required: true + type: boolean + desktop: + required: true + type: boolean + desktop_rust: + required: true + type: boolean + outputs: + desktop_macos_result: + value: ${{ jobs.results.outputs.desktop_macos_result }} + +env: + CARGO_TERM_COLOR: always + BUZZ_TEST_POSTGRES_PASSWORD: buzz_dev + PLAYWRIGHT_BROWSERS_PATH: ${{ github.workspace }}/.cache/ms-playwright + +jobs: + desktop-build-macos: + name: Desktop Build (macOS) + runs-on: macos-latest + timeout-minutes: 45 + if: github.event_name == 'push' || inputs.desktop || inputs.desktop_rust || inputs.rust + permissions: + contents: read + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 + - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 + with: + workspaces: desktop/src-tauri + save-if: ${{ github.event_name != 'pull_request' }} + - name: Install desktop dependencies + run: just desktop-install-ci + - name: Create sidecar placeholders + run: | + TARGET=$(rustc -vV | sed -n 's|host: ||p') + mkdir -p desktop/src-tauri/binaries + touch "desktop/src-tauri/binaries/buzz-acp-$TARGET" + touch "desktop/src-tauri/binaries/buzz-agent-$TARGET" + touch "desktop/src-tauri/binaries/buzz-backend-kubernetes-$TARGET" + touch "desktop/src-tauri/binaries/buzz-dev-mcp-$TARGET" + touch "desktop/src-tauri/binaries/git-credential-nostr-$TARGET" + touch "desktop/src-tauri/binaries/buzz-$TARGET" + # Mesh rev is derived from Cargo.lock so a dependency bump needs no + # lockstep edit here; the cache key tracks it automatically. + - name: Resolve mesh-llm rev + id: mesh_rev + run: | + set -euo pipefail + REV=$(python3 -c 'import tomllib; d=tomllib.load(open("Cargo.lock", "rb")); p=next(p for p in d["package"] if p["name"] == "mesh-llm-sdk"); print(p["source"].rsplit("#", 1)[1])') + [[ -n "$REV" ]] || { echo "::error::could not resolve mesh-llm rev from Cargo.lock"; exit 1; } + echo "rev=$REV" >> "$GITHUB_OUTPUT" + echo "short=${REV:0:7}" >> "$GITHUB_OUTPUT" + - name: Restore mesh llama build cache + id: llama_cache + uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5 + with: + path: ${{ github.workspace }}/.cache/mesh-llama + key: mesh-llama-${{ runner.os }}-metal-${{ steps.mesh_rev.outputs.rev }} + - name: Build mesh llama native libraries + if: steps.llama_cache.outputs.cache-hit != 'true' + env: + MESH_REV_SHORT: ${{ steps.mesh_rev.outputs.short }} + run: | + set -euo pipefail + cargo fetch --manifest-path desktop/src-tauri/Cargo.toml + SHORT="$MESH_REV_SHORT" + MESH_ROOT=$(find "${CARGO_HOME:-$HOME/.cargo}/git/checkouts" -path "*/$SHORT" -type d -name "$SHORT" | head -1) + if [[ -z "$MESH_ROOT" ]]; then + echo "::error::mesh-llm checkout for $SHORT not found after cargo fetch" + exit 1 + fi + export LLAMA_STAGE_BACKEND=metal + export LLAMA_STAGE_BUILD_DIR="$GITHUB_WORKSPACE/.cache/mesh-llama/build-stage-abi-metal" + export CMAKE_OSX_DEPLOYMENT_TARGET=10.15 + "$MESH_ROOT/scripts/prepare-llama.sh" pinned + "$MESH_ROOT/scripts/build-llama.sh" -DCMAKE_OSX_DEPLOYMENT_TARGET=10.15 + - name: Save mesh llama build cache + if: steps.llama_cache.outputs.cache-hit != 'true' + uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5 + with: + path: ${{ github.workspace }}/.cache/mesh-llama + key: mesh-llama-${{ runner.os }}-metal-${{ steps.mesh_rev.outputs.rev }} + - name: Build Tauri app + run: cd desktop && pnpm tauri build + env: + CMAKE_POLICY_VERSION_MINIMUM: "3.5" + MACOSX_DEPLOYMENT_TARGET: "10.15" + CMAKE_OSX_DEPLOYMENT_TARGET: "10.15" + LLAMA_STAGE_BACKEND: metal + LLAMA_STAGE_BUILD_DIR: ${{ github.workspace }}/.cache/mesh-llama/build-stage-abi-metal + SKIPPY_LLAMA_AUTO_BUILD: "0" + + results: + name: Results + if: ${{ always() }} + needs: [desktop-build-macos] + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: {} + outputs: + desktop_macos_result: ${{ needs.desktop-build-macos.result }} + steps: + - run: echo "Captured Desktop Build (macOS) result" diff --git a/.github/workflows/_ci-desktop.yml b/.github/workflows/_ci-desktop.yml new file mode 100644 index 0000000000..667b484141 --- /dev/null +++ b/.github/workflows/_ci-desktop.yml @@ -0,0 +1,244 @@ +name: CI / Desktop +on: + workflow_call: + inputs: + rust: + required: true + type: boolean + desktop: + required: true + type: boolean + desktop_rust: + required: true + type: boolean + outputs: + desktop_result: + value: ${{ jobs.results.outputs.desktop_result }} + +env: + CARGO_TERM_COLOR: always + BUZZ_TEST_POSTGRES_PASSWORD: buzz_dev + PLAYWRIGHT_BROWSERS_PATH: ${{ github.workspace }}/.cache/ms-playwright + +jobs: + desktop-core: + name: Desktop Core + runs-on: ubuntu-latest + timeout-minutes: 45 + if: github.event_name == 'push' || inputs.desktop || inputs.desktop_rust || inputs.rust + permissions: + contents: read + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + fetch-depth: 2 + - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 + - uses: rui314/setup-mold@7e4f20ad28a2e8ca6fd0892ccf72e2abb706b9c3 # v1 + - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 + with: + workspaces: desktop/src-tauri + save-if: ${{ github.event_name != 'pull_request' }} + - name: Install Tauri dependencies (Linux) + env: + DEBIAN_FRONTEND: noninteractive + run: | + sudo apt-get update \ + -o Acquire::Retries=3 \ + -o Acquire::http::Timeout=30 \ + -o Acquire::https::Timeout=30 + sudo apt-get install -y --no-install-recommends \ + -o Acquire::Retries=3 \ + -o Acquire::http::Timeout=30 \ + -o Acquire::https::Timeout=30 \ + -o DPkg::Lock::Timeout=120 \ + build-essential \ + curl \ + file \ + libasound2-dev \ + libayatana-appindicator3-dev \ + libgtk-3-dev \ + librsvg2-dev \ + libssl-dev \ + libwebkit2gtk-4.1-dev \ + libxdo-dev \ + patchelf \ + wget + - name: Get pnpm store directory + id: pnpm-cache + run: echo "STORE_PATH=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT" + - name: Restore pnpm store cache + uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5 + with: + path: ${{ steps.pnpm-cache.outputs.STORE_PATH }} + key: pnpm-${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }} + restore-keys: pnpm-${{ runner.os }}- + - name: Install desktop dependencies + run: just desktop-install-ci + - name: Desktop lint and format + run: just desktop-check + - name: Desktop unit tests + run: just desktop-test + - name: Desktop build + run: just desktop-build + - name: Desktop Tauri clippy + run: just desktop-tauri-clippy + env: + CMAKE_POLICY_VERSION_MINIMUM: "3.5" + - name: Desktop Tauri check + run: just desktop-tauri-check + env: + CMAKE_POLICY_VERSION_MINIMUM: "3.5" + - name: Desktop Tauri tests + run: just desktop-tauri-test + env: + CMAKE_POLICY_VERSION_MINIMUM: "3.5" + - name: Desktop Tauri compiled-flag verification + run: just desktop-tauri-test-compiled-flags + env: + CMAKE_POLICY_VERSION_MINIMUM: "3.5" + - name: Upload desktop e2e artifacts + if: failure() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: desktop-e2e-artifacts + path: | + desktop/playwright-report + desktop/test-results + if-no-files-found: ignore + - name: Save pnpm store cache + if: github.event_name == 'push' + uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5 + with: + path: ${{ steps.pnpm-cache.outputs.STORE_PATH }} + key: pnpm-${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }} + + desktop-smoke-e2e: + name: Desktop Smoke E2E (${{ matrix.shard }}) + runs-on: ubuntu-latest + timeout-minutes: 30 + if: github.event_name == 'push' || inputs.desktop || inputs.desktop_rust || inputs.rust + strategy: + fail-fast: false + matrix: + shard: [1, 2, 3, 4] + permissions: + contents: read + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 + - name: Get pnpm store directory + id: pnpm-cache + run: echo "STORE_PATH=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT" + - name: Restore pnpm store cache + uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5 + with: + path: ${{ steps.pnpm-cache.outputs.STORE_PATH }} + key: pnpm-${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }} + restore-keys: pnpm-${{ runner.os }}- + - name: Install desktop dependencies + run: just desktop-install-ci + - name: Get Playwright version + id: pw-version + run: echo "version=$(cd desktop && node -e "console.log(require('@playwright/test/package.json').version)")" >> "$GITHUB_OUTPUT" + - name: Restore Playwright browser cache + id: playwright-cache + uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5 + with: + path: ${{ env.PLAYWRIGHT_BROWSERS_PATH }} + key: playwright-${{ runner.os }}-${{ steps.pw-version.outputs.version }} + - name: Install Playwright Chromium + if: steps.playwright-cache.outputs.cache-hit != 'true' + run: cd desktop && pnpm exec playwright install chromium + - name: Install Playwright system dependencies + run: cd desktop && pnpm exec playwright install-deps chromium + - name: Save Playwright browser cache + if: steps.playwright-cache.outputs.cache-hit != 'true' && github.event_name == 'push' && matrix.shard == 1 + uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5 + with: + path: ${{ env.PLAYWRIGHT_BROWSERS_PATH }} + key: playwright-${{ runner.os }}-${{ steps.pw-version.outputs.version }} + - name: Desktop E2E build + run: pnpm -C desktop build:e2e + - name: Desktop smoke e2e + run: cd desktop && pnpm exec playwright test --project=smoke --shard=${{ matrix.shard }}/4 + - name: Summarize flaky tests + if: ${{ !cancelled() }} + run: node scripts/summarize-flaky-tests.mjs playwright-report.json "Desktop Smoke E2E (${{ matrix.shard }})" + working-directory: desktop + - name: Upload desktop smoke e2e artifacts + if: ${{ !cancelled() }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: desktop-smoke-e2e-artifacts-${{ matrix.shard }} + path: | + desktop/playwright-report + desktop/playwright-report.json + desktop/test-results + if-no-files-found: ignore + retention-days: 7 + + desktop: + name: Desktop + runs-on: ubuntu-latest + timeout-minutes: 5 + needs: [desktop-core, desktop-smoke-e2e, desktop-windows-build] + if: always() && (github.event_name == 'push' || inputs.desktop || inputs.desktop_rust || inputs.rust) + permissions: + contents: read + steps: + - name: Check desktop jobs + run: | + if [ "${{ needs.desktop-core.result }}" != "success" ]; then + echo "Desktop Core finished with: ${{ needs.desktop-core.result }}" + exit 1 + fi + if [ "${{ needs.desktop-smoke-e2e.result }}" != "success" ]; then + echo "Desktop Smoke E2E shards finished with: ${{ needs.desktop-smoke-e2e.result }}" + exit 1 + fi + if [ "${{ needs.desktop-windows-build.result }}" != "success" ]; then + echo "Desktop Windows Build finished with: ${{ needs.desktop-windows-build.result }}" + exit 1 + fi + echo "Desktop jobs passed" + + desktop-windows-build: + name: Desktop Windows Build + runs-on: windows-latest + timeout-minutes: 20 + if: github.event_name == 'push' || inputs.desktop || inputs.desktop_rust || inputs.rust + permissions: + contents: read + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: 24.14.1 + package-manager-cache: false + - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0 + with: + version: 11.4.0 + - name: Install desktop dependencies + shell: bash + run: pnpm install --frozen-lockfile + - name: Build both protected-feature selections + shell: pwsh + run: | + Remove-Item Env:VITE_BUZZ_BESTIE -ErrorAction SilentlyContinue + pnpm -C desktop build + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + $env:VITE_BUZZ_BESTIE = "1" + pnpm -C desktop build + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + + results: + name: Results + if: ${{ always() }} + needs: [desktop] + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: {} + outputs: + desktop_result: ${{ needs.desktop.result }} + steps: + - run: echo "Captured Desktop job results" diff --git a/.github/workflows/_ci-relay.yml b/.github/workflows/_ci-relay.yml new file mode 100644 index 0000000000..c0fc3d5333 --- /dev/null +++ b/.github/workflows/_ci-relay.yml @@ -0,0 +1,611 @@ +name: CI / Relay and PostgreSQL +on: + workflow_call: + inputs: + rust: + required: true + type: boolean + desktop: + required: true + type: boolean + desktop_rust: + required: true + type: boolean + lane: + required: true + type: string + outputs: + desktop_e2e_relay_result: + value: ${{ jobs.results.outputs.desktop_e2e_relay_result }} + desktop_e2e_integration_result: + value: ${{ jobs.results.outputs.desktop_e2e_integration_result }} + backend_integration_result: + value: ${{ jobs.results.outputs.backend_integration_result }} + relay_e2e_result: + value: ${{ jobs.results.outputs.relay_e2e_result }} + postgres_tests_result: + value: ${{ jobs.results.outputs.postgres_tests_result }} + +env: + CARGO_TERM_COLOR: always + BUZZ_TEST_POSTGRES_PASSWORD: buzz_dev + PLAYWRIGHT_BROWSERS_PATH: ${{ github.workspace }}/.cache/ms-playwright + +jobs: + desktop-e2e-relay: + name: Desktop E2E Relay + runs-on: ubuntu-latest + timeout-minutes: 30 + if: inputs.lane == 'artifacts' && (github.event_name == 'push' || inputs.desktop || inputs.desktop_rust || inputs.rust) + permissions: + contents: read + env: + SCCACHE_GHA_ENABLED: "true" + SCCACHE_GHA_RW_MODE: ${{ (github.event_name == 'push' || (github.event_name == 'pull_request' && github.event.pull_request.number == 5224)) && 'READ_WRITE' || 'READ_ONLY' }} + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 + # Reuse the relay binaries and backend test archive when none of their + # inputs changed (desktop-only PRs hit this every time). The key covers + # everything they embed, including migrations via sqlx migrate!. + - name: Restore relay artifacts cache + id: relay-artifacts-cache + uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5 + with: + path: | + target/ci/buzz-relay + target/ci/git-credential-nostr + target/ci/backend-integration-tests.tar.zst + target/ci/postgres-tests.tar.zst + key: relay-artifacts-${{ runner.os }}-${{ hashFiles('crates/**', 'migrations/**', 'Dockerfile', 'Cargo.toml', 'Cargo.lock', 'rust-toolchain.toml', '.cargo/config.toml', '.config/nextest.toml', 'scripts/postgres-test-*.sh', 'scripts/check-postgres-test-discovery.py', '.github/workflows/ci.yml', '.github/workflows/_ci-*.yml') }} + - uses: rui314/setup-mold@7e4f20ad28a2e8ca6fd0892ccf72e2abb706b9c3 # v1 + if: steps.relay-artifacts-cache.outputs.cache-hit != 'true' + - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 + if: steps.relay-artifacts-cache.outputs.cache-hit != 'true' + with: + workspaces: | + . + desktop/src-tauri + save-if: ${{ github.event_name != 'pull_request' }} + # Cache rustc outputs for unchanged workspace crates. Trusted pushes write; + # the bounded PR 5224 trial writes only to its isolated merge-ref scope. + - name: Set up sccache + if: steps.relay-artifacts-cache.outputs.cache-hit != 'true' + uses: Mozilla-Actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11 # zizmor: ignore[cache-poisoning] Bounded trial: only PR 5224 writes to its isolated merge-ref scope; trusted pushes retain production writes. + with: + version: v0.16.0 + - name: Install cargo-nextest + if: steps.relay-artifacts-cache.outputs.cache-hit != 'true' + uses: taiki-e/install-action@0fd46367812ee04360509b4169d9f659d6892bb2 # v2.79.15 + with: + tool: cargo-nextest@0.9.136 + - name: Build relay artifacts + if: steps.relay-artifacts-cache.outputs.cache-hit != 'true' + env: + RUSTC_WRAPPER: sccache + run: | + cargo build --profile ci -p buzz-relay -p git-credential-nostr + cargo nextest archive \ + --cargo-profile ci \ + -p buzz-db \ + -p buzz-relay \ + -p buzz-test-client \ + --lib \ + --test e2e_event_reminder \ + --archive-file target/ci/backend-integration-tests.tar.zst + postgres_package_args=() + while IFS= read -r package; do + postgres_package_args+=(-p "$package") + done < <(scripts/postgres-test-packages.sh) + if [[ "${#postgres_package_args[@]}" -eq 0 ]]; then + echo "no PostgreSQL test packages were discovered" >&2 + exit 1 + fi + cargo nextest archive \ + --cargo-profile ci \ + "${postgres_package_args[@]}" \ + --lib \ + --tests \ + --archive-file target/ci/postgres-tests.tar.zst + - name: Save relay artifacts cache + # PR-scoped exact-source entries cannot warm main or other PRs and churn + # the shared cache pool. sccache provides read-only PR reuse instead. + if: steps.relay-artifacts-cache.outputs.cache-hit != 'true' && github.event_name == 'push' + uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5 + with: + path: | + target/ci/buzz-relay + target/ci/git-credential-nostr + target/ci/backend-integration-tests.tar.zst + target/ci/postgres-tests.tar.zst + key: relay-artifacts-${{ runner.os }}-${{ hashFiles('crates/**', 'migrations/**', 'Dockerfile', 'Cargo.toml', 'Cargo.lock', 'rust-toolchain.toml', '.cargo/config.toml', '.config/nextest.toml', 'scripts/postgres-test-*.sh', 'scripts/check-postgres-test-discovery.py', '.github/workflows/ci.yml', '.github/workflows/_ci-*.yml') }} + - name: Upload relay artifacts + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: desktop-e2e-relay + path: | + target/ci/buzz-relay + target/ci/git-credential-nostr + target/ci/backend-integration-tests.tar.zst + target/ci/postgres-tests.tar.zst + if-no-files-found: error + retention-days: 1 + + postgres-tests: + name: PostgreSQL Tests + runs-on: ubuntu-latest + timeout-minutes: 10 + if: inputs.lane == 'postgres' && (github.event_name == 'push' || inputs.rust) + permissions: + contents: read + services: + postgres: + image: postgres:16 + env: + POSTGRES_USER: buzz + POSTGRES_PASSWORD: ${{ env.BUZZ_TEST_POSTGRES_PASSWORD }} + POSTGRES_DB: postgres + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U buzz -d postgres" + --health-interval 5s + --health-timeout 5s + --health-retries 12 + redis: + image: redis:7 + ports: + - 6379:6379 + options: >- + --health-cmd "redis-cli ping" + --health-interval 5s + --health-timeout 5s + --health-retries 12 + env: + PGHOST: localhost + PGPORT: "5432" + PGUSER: buzz + PG_BIN_DIR: /usr/bin + REDIS_URL: redis://localhost:6379 + PGSCHEMA_PLAN_HOST: localhost + PGSCHEMA_PLAN_PORT: "5432" + PGSCHEMA_PLAN_USER: buzz + PGSCHEMA_PLAN_PASSWORD: buzz_dev + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 + - name: Tune disposable PostgreSQL + env: + PGPASSWORD: ${{ env.BUZZ_TEST_POSTGRES_PASSWORD }} + run: | + # Database-per-test cloning forces checkpoints. Durability is redundant + # for this disposable service and makes runtime depend on runner disk I/O. + psql --dbname postgres --set ON_ERROR_STOP=1 <<'SQL' + ALTER SYSTEM SET fsync = off; + ALTER SYSTEM SET full_page_writes = off; + ALTER SYSTEM SET synchronous_commit = off; + SELECT pg_reload_conf(); + SQL + psql --dbname postgres --tuples-only --no-align --command \ + "SELECT bool_and(setting = 'off') FROM pg_settings WHERE name IN ('fsync', 'full_page_writes', 'synchronous_commit')" \ + | grep --fixed-strings --line-regexp t + - name: Install cargo-nextest + uses: taiki-e/install-action@0fd46367812ee04360509b4169d9f659d6892bb2 # v2.79.15 + with: + tool: cargo-nextest@0.9.136 + - name: Download backend test archive + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: desktop-e2e-relay + path: target/ci + - name: PostgreSQL-backed tests + env: + BUZZ_POSTGRES_ADMIN_URL: postgres://buzz:${{ env.BUZZ_TEST_POSTGRES_PASSWORD }}@localhost:5432/postgres + PGPASSWORD: ${{ env.BUZZ_TEST_POSTGRES_PASSWORD }} + run: | + scripts/postgres-test-run.sh \ + --archive-file target/ci/postgres-tests.tar.zst + + desktop-e2e-integration-shard: + name: Desktop E2E Integration (${{ matrix.shard }}/2) + runs-on: ubuntu-latest + timeout-minutes: 20 + if: inputs.lane == 'required' && (github.event_name == 'push' || inputs.desktop || inputs.desktop_rust || inputs.rust) + strategy: + fail-fast: false + matrix: + shard: [1, 2] + permissions: + contents: read + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 + - name: Start integration services + run: | + for attempt in 1 2 3; do + if docker compose up -d postgres redis minio minio-init; then + break + fi + if [ "$attempt" -eq 3 ]; then + echo "docker compose up failed after 3 attempts" >&2 + exit 1 + fi + echo "docker compose up failed (attempt $attempt), retrying in $((attempt * 5))s..." >&2 + sleep $((attempt * 5)) + done + - name: Get pnpm store directory + id: pnpm-cache + run: echo "STORE_PATH=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT" + - name: Restore pnpm store cache + uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5 + with: + path: ${{ steps.pnpm-cache.outputs.STORE_PATH }} + key: pnpm-${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }} + restore-keys: pnpm-${{ runner.os }}- + - name: Install desktop dependencies + run: just desktop-install-ci + - name: Get Playwright version + id: pw-version + run: echo "version=$(cd desktop && node -e "console.log(require('@playwright/test/package.json').version)")" >> "$GITHUB_OUTPUT" + - name: Restore Playwright browser cache + id: playwright-cache + uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5 + with: + path: ${{ env.PLAYWRIGHT_BROWSERS_PATH }} + key: playwright-${{ runner.os }}-${{ steps.pw-version.outputs.version }} + - name: Install Playwright Chromium + if: steps.playwright-cache.outputs.cache-hit != 'true' + run: cd desktop && pnpm exec playwright install chromium + - name: Install Playwright system dependencies + run: cd desktop && pnpm exec playwright install-deps chromium + - name: Save Playwright browser cache + if: steps.playwright-cache.outputs.cache-hit != 'true' && github.event_name == 'push' && matrix.shard == 1 + uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5 + with: + path: ${{ env.PLAYWRIGHT_BROWSERS_PATH }} + key: playwright-${{ runner.os }}-${{ steps.pw-version.outputs.version }} + - name: Desktop E2E build + run: pnpm -C desktop build:e2e + - name: Wait for integration services + run: | + wait_healthy() { + local service="$1" + local container="$2" + for attempt in $(seq 1 60); do + status=$(docker inspect --format='{{.State.Health.Status}}' "${container}" 2>/dev/null || echo "not_found") + if [ "${status}" = "healthy" ]; then + echo "${service} is healthy" + return 0 + fi + sleep 2 + done + docker logs "${container}" || true + return 1 + } + wait_healthy "Postgres" "buzz-postgres" + wait_healthy "Redis" "buzz-redis" + wait_healthy "MinIO" "buzz-minio" + - name: Download relay binary + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: desktop-e2e-relay + path: target/ci + - name: Apply schema and seed deployment community + # MT: the relay resolves each request's tenant from the communities host + # map and fails closed on an unmapped host. The channel reconciler binds + # the deployment community ONCE at boot (outside its retry loop) and + # exits permanently on an unmapped host, so the 'localhost:3000' + # community MUST exist before the relay starts — the retry loop only + # handles late-seeded channels, not a late-seeded community. The relay + # migrates at boot via BUZZ_AUTO_MIGRATE, but that's too late for the + # pre-boot seed, so apply the schema here first (then drop AUTO_MIGRATE + # below). lower(host) is the unique index → ON CONFLICT target. psql + # isn't on PATH in hermit → exec into the buzz-postgres container. + env: + PGHOST: localhost + PGPORT: "5432" + PGUSER: buzz + PGPASSWORD: buzz_dev + PGDATABASE: buzz + # Use the already-running docker postgres for desired-state planning instead of + # downloading an embedded Postgres from Maven Central (transient-fetch flake source). + PGSCHEMA_PLAN_HOST: localhost + PGSCHEMA_PLAN_PORT: "5432" + PGSCHEMA_PLAN_DB: buzz + PGSCHEMA_PLAN_USER: buzz + PGSCHEMA_PLAN_PASSWORD: buzz_dev + run: | + ./bin/pgschema apply --file schema/schema.sql --auto-approve + docker exec -i -e PGPASSWORD=buzz_dev buzz-postgres \ + psql -U buzz -d buzz -v ON_ERROR_STOP=1 < scripts/reconcile-schema-after-pgschema.sql + docker exec -e PGPASSWORD=buzz_dev buzz-postgres \ + psql -U buzz -d buzz -qtA -c " + INSERT INTO communities (id, host) + VALUES ('00000000-0000-4000-8000-00000000c0de', 'localhost:3000') + ON CONFLICT (lower(host)) DO NOTHING + ;" + - name: Start relay + run: | + chmod +x ./target/ci/buzz-relay + nohup env \ + DATABASE_URL="postgres://buzz:${BUZZ_TEST_POSTGRES_PASSWORD}@localhost:5432/buzz" \ + REDIS_URL=redis://localhost:6379 \ + RELAY_URL=ws://localhost:3000 \ + BUZZ_BIND_ADDR=0.0.0.0:3000 \ + BUZZ_RELAY_PRIVATE_KEY="$(openssl rand -hex 32)" \ + BUZZ_REQUIRE_AUTH_TOKEN=false \ + BUZZ_RECONCILE_CHANNELS=true \ + BUZZ_RATE_LIMIT_HUMAN_MESSAGES_PER_MIN=100000 \ + BUZZ_RATE_LIMIT_HUMAN_API_CALLS_PER_MIN=100000 \ + BUZZ_RATE_LIMIT_HUMAN_WS_EVENTS_PER_SEC=10000 \ + BUZZ_GIT_PROBE_WRITERS=8 \ + SPROUT_REMINDER_SCHEDULER_INTERVAL_SECS=1 \ + ./target/ci/buzz-relay > /tmp/buzz-relay.log 2>&1 & + echo $! > /tmp/buzz-relay.pid + for attempt in $(seq 1 60); do + if ! kill -0 "$(cat /tmp/buzz-relay.pid)" 2>/dev/null; then + cat /tmp/buzz-relay.log + exit 1 + fi + status_code=$(curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:3000/_readiness || true) + if [ "${status_code}" = "200" ]; then + exit 0 + fi + sleep 1 + done + cat /tmp/buzz-relay.log + exit 1 + - name: Seed desktop e2e data + run: bash scripts/setup-desktop-test-data.sh + - name: Desktop relay-backed e2e + run: cd desktop && pnpm exec playwright test --project=integration --shard=${{ matrix.shard }}/2 + - name: Summarize flaky tests + if: ${{ !cancelled() }} + run: node scripts/summarize-flaky-tests.mjs playwright-report.json "Desktop E2E Integration (${{ matrix.shard }}/2)" + working-directory: desktop + - name: Upload desktop integration artifacts + if: ${{ !cancelled() }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: desktop-e2e-integration-artifacts-${{ matrix.shard }} + path: | + desktop/playwright-report + desktop/playwright-report.json + desktop/test-results + /tmp/buzz-relay.log + if-no-files-found: ignore + retention-days: 7 + - name: Save pnpm store cache + if: github.event_name == 'push' + uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5 + with: + path: ${{ steps.pnpm-cache.outputs.STORE_PATH }} + key: pnpm-${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }} + + desktop-e2e-integration: + name: Desktop E2E Integration + runs-on: ubuntu-latest + timeout-minutes: 5 + needs: [desktop-e2e-integration-shard] + if: always() && inputs.lane == 'required' && (github.event_name == 'push' || inputs.desktop || inputs.desktop_rust || inputs.rust) + permissions: + contents: read + steps: + - name: Check integration shards + run: | + if [ "${{ needs.desktop-e2e-integration-shard.result }}" != "success" ]; then + echo "Desktop E2E Integration shards finished with: ${{ needs.desktop-e2e-integration-shard.result }}" + exit 1 + fi + echo "Desktop E2E Integration shards passed" + + backend-integration: + name: Backend Integration (relay e2e) + runs-on: ubuntu-latest + timeout-minutes: 20 + if: inputs.lane == 'required' && (github.event_name == 'push' || inputs.rust) + permissions: + contents: read + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 + - name: Install cargo-nextest + uses: taiki-e/install-action@0fd46367812ee04360509b4169d9f659d6892bb2 # v2.79.15 + with: + tool: cargo-nextest@0.9.136 + - name: Start integration services + run: | + for attempt in 1 2 3; do + if docker compose up -d postgres redis minio minio-init; then + break + fi + if [ "$attempt" -eq 3 ]; then + echo "docker compose up failed after 3 attempts" >&2 + exit 1 + fi + echo "docker compose up failed (attempt $attempt), retrying in $((attempt * 5))s..." >&2 + sleep $((attempt * 5)) + done + - name: Wait for integration services + run: | + wait_healthy() { + local service="$1" + local container="$2" + for attempt in $(seq 1 60); do + status=$(docker inspect --format='{{.State.Health.Status}}' "${container}" 2>/dev/null || echo "not_found") + if [ "${status}" = "healthy" ]; then + echo "${service} is healthy" + return 0 + fi + sleep 2 + done + docker logs "${container}" || true + return 1 + } + wait_healthy "Postgres" "buzz-postgres" + wait_healthy "Redis" "buzz-redis" + wait_healthy "MinIO" "buzz-minio" + - name: Download relay artifacts + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: desktop-e2e-relay + path: target/ci + - name: Apply schema and seed deployment community + # MT: the relay resolves each request's tenant from the communities host + # map and fails closed on an unmapped host. The reminder scheduler binds + # the deployment community ONCE at boot and exits permanently on an + # unmapped host (no retry, unlike the channel reconciler), so the + # 'localhost:3000' community MUST exist before the relay starts — seeding + # after boot leaves the scheduler dead. The relay migrates at boot via + # BUZZ_AUTO_MIGRATE, but that's too late for the pre-boot seed, so apply + # the schema here first (then drop AUTO_MIGRATE below). lower(host) is the + # unique index → ON CONFLICT target. psql isn't on PATH in hermit → exec + # into the buzz-postgres container. + env: + PGHOST: localhost + PGPORT: "5432" + PGUSER: buzz + PGPASSWORD: buzz_dev + PGDATABASE: buzz + # Use the already-running docker postgres for desired-state planning instead of + # downloading an embedded Postgres from Maven Central (transient-fetch flake source). + PGSCHEMA_PLAN_HOST: localhost + PGSCHEMA_PLAN_PORT: "5432" + PGSCHEMA_PLAN_DB: buzz + PGSCHEMA_PLAN_USER: buzz + PGSCHEMA_PLAN_PASSWORD: buzz_dev + run: | + ./bin/pgschema apply --file schema/schema.sql --auto-approve + docker exec -i -e PGPASSWORD=buzz_dev buzz-postgres \ + psql -U buzz -d buzz -v ON_ERROR_STOP=1 < scripts/reconcile-schema-after-pgschema.sql + docker exec -e PGPASSWORD=buzz_dev buzz-postgres \ + psql -U buzz -d buzz -qtA -c " + INSERT INTO communities (id, host) + VALUES ('00000000-0000-4000-8000-00000000c0de', 'localhost:3000') + ON CONFLICT (lower(host)) DO NOTHING + ;" + - name: Workflow message provenance unit tests + # The relay's workflow_sink suite is not selected by the infra-free + # unit job. Its ignored database cases run in the isolated PostgreSQL + # lane; keep the pure provenance cases covered here without duplication. + run: | + cargo nextest run \ + --archive-file target/ci/backend-integration-tests.tar.zst \ + -E 'package(buzz-relay) and test(/workflow_sink/)' + - name: Start relay + run: | + chmod +x ./target/ci/buzz-relay + nohup env \ + DATABASE_URL="postgres://buzz:${BUZZ_TEST_POSTGRES_PASSWORD}@localhost:5432/buzz" \ + REDIS_URL=redis://localhost:6379 \ + RELAY_URL=ws://localhost:3000 \ + BUZZ_BIND_ADDR=0.0.0.0:3000 \ + BUZZ_RELAY_PRIVATE_KEY="$(openssl rand -hex 32)" \ + BUZZ_REQUIRE_AUTH_TOKEN=false \ + BUZZ_RECONCILE_CHANNELS=true \ + BUZZ_GIT_PROBE_WRITERS=8 \ + SPROUT_REMINDER_SCHEDULER_INTERVAL_SECS=1 \ + ./target/ci/buzz-relay > /tmp/buzz-relay.log 2>&1 & + echo $! > /tmp/buzz-relay.pid + for attempt in $(seq 1 60); do + if ! kill -0 "$(cat /tmp/buzz-relay.pid)" 2>/dev/null; then + cat /tmp/buzz-relay.log + exit 1 + fi + status_code=$(curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:3000/_readiness || true) + if [ "${status_code}" = "200" ]; then + exit 0 + fi + sleep 1 + done + cat /tmp/buzz-relay.log + exit 1 + - name: NIP-ER reminder e2e + # Feature e2e for NIP-ER (Event Reminders, kind:30300): write-path + # validation, author-only read filtering, and scheduler delivery against + # a live relay. The schema-drift / migration-version guarantee is owned + # by the buzz-db migration.rs unit tests, not this suite. + run: | + cargo nextest run \ + --archive-file target/ci/backend-integration-tests.tar.zst \ + -E 'binary(e2e_event_reminder)' \ + --run-ignored ignored-only + env: + RELAY_URL: ws://localhost:3000 + - name: Upload relay log + if: failure() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: backend-integration-relay-log + path: /tmp/buzz-relay.log + if-no-files-found: ignore + + relay-e2e: + name: Relay E2E + runs-on: ubuntu-latest + timeout-minutes: 20 + if: inputs.lane == 'required' && (github.event_name == 'push' || inputs.rust) + permissions: + contents: read + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 + - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 + with: + save-if: ${{ github.event_name != 'pull_request' }} + # Reuse the relay + git-credential-nostr built by Desktop E2E Relay + # instead of compiling them a second time. + - name: Download relay binary + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: desktop-e2e-relay + path: target/ci + - name: Start relay + run: | + chmod +x ./target/ci/buzz-relay ./target/ci/git-credential-nostr + ./scripts/start-relay-for-tests.sh --no-build + - name: Relay E2E tests + run: | + cargo test -p buzz-test-client --test e2e_persona --test e2e_team_catalog --test e2e_nostr_interop --test e2e_project -- --ignored --nocapture + cargo test -p buzz-test-client --test e2e_relay invite -- --ignored --nocapture + cargo test -p buzz-test-client --test e2e_relay nip43_membership_snapshots_are_rejected -- --ignored --nocapture + env: + RELAY_URL: ws://localhost:3000 + GIT_CREDENTIAL_NOSTR_BIN: ${{ github.workspace }}/target/ci/git-credential-nostr + - name: Media read-auth e2e + # Reads require kind:24242 `t=get` auth, so these binaries are the only + # coverage that a real relay rejects bare reads and honours host- and + # hash-scoped tokens. They were #[ignore]d and selected by no CI job, so + # the lane never ran; select it here, where MinIO and the seeded + # 'localhost:3000' community already exist. + # --no-fail-fast: without it cargo stops after the first failing binary, + # so one broken case hides every later binary's result. + run: | + cargo test -p buzz-test-client --no-fail-fast --test e2e_media --test e2e_media_extended --test e2e_media_video -- --ignored --nocapture + env: + RELAY_URL: ws://localhost:3000 + RELAY_HTTP_URL: http://localhost:3000 + - name: Upload relay logs + if: failure() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: relay-e2e-artifacts + path: /tmp/buzz-relay.log + if-no-files-found: ignore + + + results: + name: Results + if: ${{ always() }} + needs: [desktop-e2e-relay, postgres-tests, desktop-e2e-integration, backend-integration, relay-e2e] + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: {} + outputs: + desktop_e2e_relay_result: ${{ needs.desktop-e2e-relay.result }} + desktop_e2e_integration_result: ${{ needs.desktop-e2e-integration.result }} + backend_integration_result: ${{ needs.backend-integration.result }} + relay_e2e_result: ${{ needs.relay-e2e.result }} + postgres_tests_result: ${{ needs.postgres-tests.result }} + steps: + - run: echo "Captured Relay and PostgreSQL job results" diff --git a/.github/workflows/_ci-rust.yml b/.github/workflows/_ci-rust.yml new file mode 100644 index 0000000000..f49cc96eb2 --- /dev/null +++ b/.github/workflows/_ci-rust.yml @@ -0,0 +1,219 @@ +name: CI / Rust +on: + workflow_call: + inputs: + rust: + required: true + type: boolean + desktop_rust: + required: true + type: boolean + lane: + required: true + type: string + outputs: + rust_lint_result: + value: ${{ jobs.results.outputs.rust_lint_result }} + unit_tests_result: + value: ${{ jobs.results.outputs.unit_tests_result }} + windows_rust_result: + value: ${{ jobs.results.outputs.windows_rust_result }} + +env: + CARGO_TERM_COLOR: always + BUZZ_TEST_POSTGRES_PASSWORD: buzz_dev + PLAYWRIGHT_BROWSERS_PATH: ${{ github.workspace }}/.cache/ms-playwright + +jobs: + rust-lint: + name: Rust Lint + runs-on: ubuntu-latest + timeout-minutes: 30 + if: inputs.lane == 'required' && (github.event_name == 'push' || inputs.rust || inputs.desktop_rust) + permissions: + contents: read + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 + - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 + with: + save-if: ${{ github.event_name != 'pull_request' }} + - name: Format check + run: just fmt-check + - name: Desktop Tauri format check + run: just desktop-tauri-fmt-check + - name: Clippy + run: just clippy + + unit-tests: + name: Unit Tests + runs-on: ubuntu-latest + timeout-minutes: 30 + if: inputs.lane == 'required' && (github.event_name == 'push' || inputs.rust) + permissions: + contents: read + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 + - uses: rui314/setup-mold@7e4f20ad28a2e8ca6fd0892ccf72e2abb706b9c3 # v1 + - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 + with: + key: sherpa-cache-v1 + save-if: ${{ github.event_name != 'pull_request' }} + - name: Install cargo-nextest + uses: taiki-e/install-action@0fd46367812ee04360509b4169d9f659d6892bb2 # v2.79.15 + with: + tool: cargo-nextest@0.9.136 + - name: Unit tests + run: just test-unit + + server-cross-compile: + name: Server Cross-Compile + runs-on: ubuntu-latest + timeout-minutes: 30 + if: inputs.lane == 'cross-compile' && (github.event_name == 'push' || inputs.rust) + permissions: + contents: read + strategy: + fail-fast: false + matrix: + target: + - x86_64-unknown-linux-musl + - aarch64-unknown-linux-musl + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 + - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 + with: + key: cross-${{ matrix.target }} + save-if: ${{ github.event_name != 'pull_request' }} + - name: Install cross + uses: taiki-e/install-action@0fd46367812ee04360509b4169d9f659d6892bb2 # v2.79.15 + with: + tool: cross@0.2.5 + - name: Build server binaries + env: + TARGET: ${{ matrix.target }} + # PRs: compile + build-script gate only (no codegen/link). Main: full link gate. + CARGO_CMD: ${{ github.event_name == 'pull_request' && 'check' || 'build' }} + run: | + cross "$CARGO_CMD" --release --target "$TARGET" \ + -p buzz-relay \ + -p buzz-acp \ + -p buzz-agent \ + -p buzz-dev-mcp \ + -p git-credential-nostr \ + -p git-sign-nostr + + windows-rust: + name: Windows Rust (x86_64-pc-windows-msvc) + runs-on: windows-latest + # Windows runners are slow and this compiles the workspace + Tauri crate + # cold across four steps; budget generously. + timeout-minutes: 45 + if: inputs.lane == 'required' && (github.event_name == 'push' || inputs.rust || inputs.desktop_rust) + permissions: + contents: read + env: + TARGET: x86_64-pc-windows-msvc + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + # MSVC needs windows.h (aws-lc-sys et al.), so this runs on a real Windows + # runner — hermit, used by the Linux jobs, does not provide MSVC. The + # toolchain (1.95.0 + clippy via profile = default) comes from the + # repo-root rust-toolchain.toml, which the runner's preinstalled rustup + # honors on demand; the host triple already is x86_64-pc-windows-msvc. + - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 + with: + workspaces: | + . + desktop/src-tauri + key: windows-msvc + save-if: ${{ github.event_name != 'pull_request' }} + # Tauri validates externalBin at compile time, so the Tauri-crate steps + # below fail without these stubs. Mirrors scripts/bundle-sidecars.sh's + # Windows naming (binaries/-.exe); empty files suffice for a + # type-check since nothing executes them. + - name: Create sidecar placeholders + shell: bash + run: | + mkdir -p desktop/src-tauri/binaries + for bin in buzz-acp buzz-agent buzz-dev-mcp git-credential-nostr buzz; do + touch "desktop/src-tauri/binaries/${bin}-${TARGET}.exe" + done + - name: Clippy (workspace) + run: cargo clippy --workspace --all-targets --target $env:TARGET -- -D warnings + - name: Check (workspace) + run: cargo check --workspace --all-targets --target $env:TARGET + - name: Test (buzz-dev-mcp) + # The Windows-only bash resolver lives in buzz-dev-mcp; its unit tests + # only gate if this crate is tested ON Windows. + # Serial: windows_resolver_tests mutate process-global env + # (BUZZ_SHELL/GIT_BASH/SystemRoot) that SharedState::new reads. + run: cargo test -p buzz-dev-mcp --target $env:TARGET -- --test-threads=1 + - name: Test (buzz-agent auth coordinator) + # The auth coordinator single-flights on an OS advisory lock, which is + # LockFileEx on Windows; this integration suite drives real second + # processes on the same lock file, so it only exercises the Windows + # lock runtime if it runs ON Windows. Every other job compiles it but + # never executes it. Tests exercised on Windows: lock serialization + # (two coordinators race for the same key), cooldown sidecar sharing + # across processes, attempt-sidecar adoption (UserInitiated waiter + # adopts a predecessor's denial), and the in-process single-flight for + # same-key coalescing. Tests that are UNIX-ONLY and NOT executed here: + # crash-release (flock drop on SIGKILL, guarded by #[cfg(unix)]) and + # cross-process cache success/race (on-disk token handoff, also + # #[cfg(unix)]). + run: cargo test -p buzz-agent --target $env:TARGET --test databricks_auth_coordinator + # Smoke-test the new host-prereq contract: Git for Windows (which provides + # bash) is available on the runner, a shell command round-trips, and bash + # does NOT resolve from System32 (so WSL's launcher is never picked up). + # windows-latest runners have Git for Windows pre-installed; the unit tests + # above exercise the MCP resolver itself. This step verifies the host env. + - name: Smoke-test host Git Bash prereq (host env check) + shell: bash + run: | + set -euo pipefail + # Git for Windows ships bash.exe under its bin/ directory; confirm it + # resolves from the standard location the runtime resolver probes first. + bash_path=$(command -v bash 2>/dev/null || true) + [[ -n "$bash_path" ]] || { echo "ERROR: bash not found on PATH — host Git for Windows missing" >&2; exit 1; } + echo "Resolved bash: $bash_path" + [[ "$bash_path" != *System32* ]] || { echo "ERROR: resolved bash is WSL's System32 launcher" >&2; exit 1; } + + # Run a basic pipeline through the resolved bash (same invocation the + # agent uses: bash -c '...'). + out=$(bash -c 'echo hello | tr a-z A-Z') + [[ "$out" == "HELLO" ]] || { echo "bash pipeline failed: got '$out'" >&2; exit 1; } + + # Confirm git itself works — agents run git commands frequently. + git --version + repo=$(mktemp -d) + cd "$repo" + git init -q + git -c user.name=ci -c user.email=ci@example.com commit -q --allow-empty -m smoke + git log -1 --format=%s | grep -qx smoke + echo "Host bash resolved and functional; git commit round-trip passed" + - name: Check (Tauri crate) + run: cargo check --manifest-path desktop/src-tauri/Cargo.toml --workspace --all-targets --target $env:TARGET + env: + CMAKE_POLICY_VERSION_MINIMUM: "3.5" + - name: Test (Tauri crate) + run: cargo test --manifest-path desktop/src-tauri/Cargo.toml --target $env:TARGET + env: + CMAKE_POLICY_VERSION_MINIMUM: "3.5" + + + results: + name: Results + if: ${{ always() }} + needs: [rust-lint, unit-tests, server-cross-compile, windows-rust] + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: {} + outputs: + rust_lint_result: ${{ needs.rust-lint.result }} + unit_tests_result: ${{ needs.unit-tests.result }} + windows_rust_result: ${{ needs.windows-rust.result }} + steps: + - run: echo "Captured Rust job results" diff --git a/.github/workflows/_ci-security.yml b/.github/workflows/_ci-security.yml new file mode 100644 index 0000000000..29855e8f78 --- /dev/null +++ b/.github/workflows/_ci-security.yml @@ -0,0 +1,42 @@ +name: CI / Security +on: + workflow_call: + inputs: + rust: + required: true + type: boolean + outputs: + security_result: + value: ${{ jobs.results.outputs.security_result }} + +env: + CARGO_TERM_COLOR: always + BUZZ_TEST_POSTGRES_PASSWORD: buzz_dev + PLAYWRIGHT_BROWSERS_PATH: ${{ github.workspace }}/.cache/ms-playwright + +jobs: + security: + name: Security + runs-on: ubuntu-latest + timeout-minutes: 20 + if: github.event_name == 'push' || inputs.rust + permissions: + contents: read + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 + - name: Dependency policy + run: cargo-deny check + + + results: + name: Results + if: ${{ always() }} + needs: [security] + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: {} + outputs: + security_result: ${{ needs.security.result }} + steps: + - run: echo "Captured Security job results" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cc7965ad29..a663ef957c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -54,6 +54,7 @@ jobs: - 'scripts/test-postgres-test-wrapper.sh' - 'deny.toml' - '.github/workflows/ci.yml' + - '.github/workflows/_ci-*.yml' - 'scripts/run-tests.sh' - 'scripts/model-capabilities.json' - 'scripts/normative-corpus.json' @@ -81,6 +82,7 @@ jobs: - 'scripts/test-mobile-worktree-overrides.sh' - '.github/workflows/mobile-release-candidate.yml' - '.github/workflows/ci.yml' + - '.github/workflows/_ci-*.yml' - name: Validate PostgreSQL test discovery if: github.event_name == 'push' || steps.filter.outputs.rust == 'true' run: | @@ -110,929 +112,11 @@ jobs: run: | scripts/test-rust-cache-contract.sh scripts/test-rust-cache-contract-regressions.sh + - name: CI required-context isolation contract + run: scripts/test-ci-required-context-isolation.sh - name: File size policy run: just file-size-check - rust-lint: - name: Rust Lint - runs-on: ubuntu-latest - timeout-minutes: 30 - needs: [changes] - if: github.event_name == 'push' || needs.changes.outputs.rust == 'true' || needs.changes.outputs.desktop-rust == 'true' - permissions: - contents: read - steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 - - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2.9.1 - with: - save-if: ${{ github.event_name != 'pull_request' }} - - name: Format check - run: just fmt-check - - name: Desktop Tauri format check - run: just desktop-tauri-fmt-check - - name: Clippy - run: just clippy - - unit-tests: - name: Unit Tests - runs-on: ubuntu-latest - timeout-minutes: 30 - needs: [changes] - if: github.event_name == 'push' || needs.changes.outputs.rust == 'true' - permissions: - contents: read - steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 - - uses: rui314/setup-mold@7e4f20ad28a2e8ca6fd0892ccf72e2abb706b9c3 # v1 - - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2.9.1 - with: - key: sherpa-cache-v1 - save-if: ${{ github.event_name != 'pull_request' }} - - name: Install cargo-nextest - uses: taiki-e/install-action@0fd46367812ee04360509b4169d9f659d6892bb2 # v2.79.15 - with: - tool: cargo-nextest@0.9.136 - - name: Unit tests - run: just test-unit - - desktop-core: - name: Desktop Core - runs-on: ubuntu-latest - timeout-minutes: 45 - needs: [changes] - if: github.event_name == 'push' || needs.changes.outputs.desktop == 'true' || needs.changes.outputs.desktop-rust == 'true' || needs.changes.outputs.rust == 'true' - permissions: - contents: read - steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - with: - fetch-depth: 2 - - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 - - uses: rui314/setup-mold@7e4f20ad28a2e8ca6fd0892ccf72e2abb706b9c3 # v1 - - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2.9.1 - with: - workspaces: desktop/src-tauri - save-if: ${{ github.event_name != 'pull_request' }} - - name: Install Tauri dependencies (Linux) - env: - DEBIAN_FRONTEND: noninteractive - run: | - sudo apt-get update \ - -o Acquire::Retries=3 \ - -o Acquire::http::Timeout=30 \ - -o Acquire::https::Timeout=30 - sudo apt-get install -y --no-install-recommends \ - -o Acquire::Retries=3 \ - -o Acquire::http::Timeout=30 \ - -o Acquire::https::Timeout=30 \ - -o DPkg::Lock::Timeout=120 \ - build-essential \ - curl \ - file \ - libasound2-dev \ - libayatana-appindicator3-dev \ - libgtk-3-dev \ - librsvg2-dev \ - libssl-dev \ - libwebkit2gtk-4.1-dev \ - libxdo-dev \ - patchelf \ - wget - - name: Get pnpm store directory - id: pnpm-cache - run: echo "STORE_PATH=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT" - - name: Restore pnpm store cache - uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5 - with: - path: ${{ steps.pnpm-cache.outputs.STORE_PATH }} - key: pnpm-${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }} - restore-keys: pnpm-${{ runner.os }}- - - name: Install desktop dependencies - run: just desktop-install-ci - - name: Desktop lint and format - run: just desktop-check - - name: Desktop unit tests - run: just desktop-test - - name: Desktop build - run: just desktop-build - - name: Desktop Tauri clippy - run: just desktop-tauri-clippy - env: - CMAKE_POLICY_VERSION_MINIMUM: "3.5" - - name: Desktop Tauri check - run: just desktop-tauri-check - env: - CMAKE_POLICY_VERSION_MINIMUM: "3.5" - - name: Desktop Tauri tests - run: just desktop-tauri-test - env: - CMAKE_POLICY_VERSION_MINIMUM: "3.5" - - name: Desktop Tauri compiled-flag verification - run: just desktop-tauri-test-compiled-flags - env: - CMAKE_POLICY_VERSION_MINIMUM: "3.5" - - name: Upload desktop e2e artifacts - if: failure() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 - with: - name: desktop-e2e-artifacts - path: | - desktop/playwright-report - desktop/test-results - if-no-files-found: ignore - - name: Save pnpm store cache - if: github.event_name == 'push' - uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5 - with: - path: ${{ steps.pnpm-cache.outputs.STORE_PATH }} - key: pnpm-${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }} - - desktop-smoke-e2e: - name: Desktop Smoke E2E (${{ matrix.shard }}) - runs-on: ubuntu-latest - timeout-minutes: 30 - needs: [changes] - if: github.event_name == 'push' || needs.changes.outputs.desktop == 'true' || needs.changes.outputs.desktop-rust == 'true' || needs.changes.outputs.rust == 'true' - strategy: - fail-fast: false - matrix: - shard: [1, 2, 3, 4] - permissions: - contents: read - steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 - - name: Get pnpm store directory - id: pnpm-cache - run: echo "STORE_PATH=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT" - - name: Restore pnpm store cache - uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5 - with: - path: ${{ steps.pnpm-cache.outputs.STORE_PATH }} - key: pnpm-${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }} - restore-keys: pnpm-${{ runner.os }}- - - name: Install desktop dependencies - run: just desktop-install-ci - - name: Get Playwright version - id: pw-version - run: echo "version=$(cd desktop && node -e "console.log(require('@playwright/test/package.json').version)")" >> "$GITHUB_OUTPUT" - - name: Restore Playwright browser cache - id: playwright-cache - uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5 - with: - path: ${{ env.PLAYWRIGHT_BROWSERS_PATH }} - key: playwright-${{ runner.os }}-${{ steps.pw-version.outputs.version }} - - name: Install Playwright Chromium - if: steps.playwright-cache.outputs.cache-hit != 'true' - run: cd desktop && pnpm exec playwright install chromium - - name: Install Playwright system dependencies - run: cd desktop && pnpm exec playwright install-deps chromium - - name: Save Playwright browser cache - if: steps.playwright-cache.outputs.cache-hit != 'true' && github.event_name == 'push' && matrix.shard == 1 - uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5 - with: - path: ${{ env.PLAYWRIGHT_BROWSERS_PATH }} - key: playwright-${{ runner.os }}-${{ steps.pw-version.outputs.version }} - - name: Desktop E2E build - run: pnpm -C desktop build:e2e - - name: Desktop smoke e2e - run: cd desktop && pnpm exec playwright test --project=smoke --shard=${{ matrix.shard }}/4 - - name: Summarize flaky tests - if: ${{ !cancelled() }} - run: node scripts/summarize-flaky-tests.mjs playwright-report.json "Desktop Smoke E2E (${{ matrix.shard }})" - working-directory: desktop - - name: Upload desktop smoke e2e artifacts - if: ${{ !cancelled() }} - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 - with: - name: desktop-smoke-e2e-artifacts-${{ matrix.shard }} - path: | - desktop/playwright-report - desktop/playwright-report.json - desktop/test-results - if-no-files-found: ignore - retention-days: 7 - - desktop: - name: Desktop - runs-on: ubuntu-latest - timeout-minutes: 5 - needs: [changes, desktop-core, desktop-smoke-e2e, desktop-windows-build] - if: always() && (github.event_name == 'push' || needs.changes.outputs.desktop == 'true' || needs.changes.outputs.desktop-rust == 'true' || needs.changes.outputs.rust == 'true') - permissions: - contents: read - steps: - - name: Check desktop jobs - run: | - if [ "${{ needs.desktop-core.result }}" != "success" ]; then - echo "Desktop Core finished with: ${{ needs.desktop-core.result }}" - exit 1 - fi - if [ "${{ needs.desktop-smoke-e2e.result }}" != "success" ]; then - echo "Desktop Smoke E2E shards finished with: ${{ needs.desktop-smoke-e2e.result }}" - exit 1 - fi - if [ "${{ needs.desktop-windows-build.result }}" != "success" ]; then - echo "Desktop Windows Build finished with: ${{ needs.desktop-windows-build.result }}" - exit 1 - fi - echo "Desktop jobs passed" - - desktop-e2e-relay: - name: Desktop E2E Relay - runs-on: ubuntu-latest - timeout-minutes: 30 - needs: [changes] - if: github.event_name == 'push' || needs.changes.outputs.desktop == 'true' || needs.changes.outputs.desktop-rust == 'true' || needs.changes.outputs.rust == 'true' - permissions: - contents: read - env: - SCCACHE_GHA_ENABLED: "true" - SCCACHE_GHA_RW_MODE: ${{ (github.event_name == 'push' || (github.event_name == 'pull_request' && github.event.pull_request.number == 5224)) && 'READ_WRITE' || 'READ_ONLY' }} - steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 - # Reuse the relay binaries and backend test archive when none of their - # inputs changed (desktop-only PRs hit this every time). The key covers - # everything they embed, including migrations via sqlx migrate!. - - name: Restore relay artifacts cache - id: relay-artifacts-cache - uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5 - with: - path: | - target/ci/buzz-relay - target/ci/git-credential-nostr - target/ci/backend-integration-tests.tar.zst - target/ci/postgres-tests.tar.zst - key: relay-artifacts-${{ runner.os }}-${{ hashFiles('crates/**', 'migrations/**', 'Dockerfile', 'Cargo.toml', 'Cargo.lock', 'rust-toolchain.toml', '.cargo/config.toml', '.config/nextest.toml', 'scripts/postgres-test-*.sh', 'scripts/check-postgres-test-discovery.py', '.github/workflows/ci.yml') }} - - uses: rui314/setup-mold@7e4f20ad28a2e8ca6fd0892ccf72e2abb706b9c3 # v1 - if: steps.relay-artifacts-cache.outputs.cache-hit != 'true' - - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2.9.1 - if: steps.relay-artifacts-cache.outputs.cache-hit != 'true' - with: - workspaces: | - . - desktop/src-tauri - save-if: ${{ github.event_name != 'pull_request' }} - # Cache rustc outputs for unchanged workspace crates. Trusted pushes write; - # the bounded PR 5224 trial writes only to its isolated merge-ref scope. - - name: Set up sccache - if: steps.relay-artifacts-cache.outputs.cache-hit != 'true' - uses: Mozilla-Actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11 # zizmor: ignore[cache-poisoning] Bounded trial: only PR 5224 writes to its isolated merge-ref scope; trusted pushes retain production writes. - with: - version: v0.16.0 - - name: Install cargo-nextest - if: steps.relay-artifacts-cache.outputs.cache-hit != 'true' - uses: taiki-e/install-action@0fd46367812ee04360509b4169d9f659d6892bb2 # v2.79.15 - with: - tool: cargo-nextest@0.9.136 - - name: Build relay artifacts - if: steps.relay-artifacts-cache.outputs.cache-hit != 'true' - env: - RUSTC_WRAPPER: sccache - run: | - cargo build --profile ci -p buzz-relay -p git-credential-nostr - cargo nextest archive \ - --cargo-profile ci \ - -p buzz-db \ - -p buzz-relay \ - -p buzz-test-client \ - --lib \ - --test e2e_event_reminder \ - --archive-file target/ci/backend-integration-tests.tar.zst - postgres_package_args=() - while IFS= read -r package; do - postgres_package_args+=(-p "$package") - done < <(scripts/postgres-test-packages.sh) - if [[ "${#postgres_package_args[@]}" -eq 0 ]]; then - echo "no PostgreSQL test packages were discovered" >&2 - exit 1 - fi - cargo nextest archive \ - --cargo-profile ci \ - "${postgres_package_args[@]}" \ - --lib \ - --tests \ - --archive-file target/ci/postgres-tests.tar.zst - - name: Save relay artifacts cache - # PR-scoped exact-source entries cannot warm main or other PRs and churn - # the shared cache pool. sccache provides read-only PR reuse instead. - if: steps.relay-artifacts-cache.outputs.cache-hit != 'true' && github.event_name == 'push' - uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5 - with: - path: | - target/ci/buzz-relay - target/ci/git-credential-nostr - target/ci/backend-integration-tests.tar.zst - target/ci/postgres-tests.tar.zst - key: relay-artifacts-${{ runner.os }}-${{ hashFiles('crates/**', 'migrations/**', 'Dockerfile', 'Cargo.toml', 'Cargo.lock', 'rust-toolchain.toml', '.cargo/config.toml', '.config/nextest.toml', 'scripts/postgres-test-*.sh', 'scripts/check-postgres-test-discovery.py', '.github/workflows/ci.yml') }} - - name: Upload relay artifacts - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 - with: - name: desktop-e2e-relay - path: | - target/ci/buzz-relay - target/ci/git-credential-nostr - target/ci/backend-integration-tests.tar.zst - target/ci/postgres-tests.tar.zst - if-no-files-found: error - retention-days: 1 - - postgres-tests: - name: PostgreSQL Tests - runs-on: ubuntu-latest - timeout-minutes: 10 - needs: [changes, desktop-e2e-relay] - if: github.event_name == 'push' || needs.changes.outputs.rust == 'true' - permissions: - contents: read - services: - postgres: - image: postgres:16 - env: - POSTGRES_USER: buzz - POSTGRES_PASSWORD: ${{ env.BUZZ_TEST_POSTGRES_PASSWORD }} - POSTGRES_DB: postgres - ports: - - 5432:5432 - options: >- - --health-cmd "pg_isready -U buzz -d postgres" - --health-interval 5s - --health-timeout 5s - --health-retries 12 - redis: - image: redis:7 - ports: - - 6379:6379 - options: >- - --health-cmd "redis-cli ping" - --health-interval 5s - --health-timeout 5s - --health-retries 12 - env: - PGHOST: localhost - PGPORT: "5432" - PGUSER: buzz - PG_BIN_DIR: /usr/bin - REDIS_URL: redis://localhost:6379 - PGSCHEMA_PLAN_HOST: localhost - PGSCHEMA_PLAN_PORT: "5432" - PGSCHEMA_PLAN_USER: buzz - PGSCHEMA_PLAN_PASSWORD: buzz_dev - steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 - - name: Tune disposable PostgreSQL - env: - PGPASSWORD: ${{ env.BUZZ_TEST_POSTGRES_PASSWORD }} - run: | - # Database-per-test cloning forces checkpoints. Durability is redundant - # for this disposable service and makes runtime depend on runner disk I/O. - psql --dbname postgres --set ON_ERROR_STOP=1 <<'SQL' - ALTER SYSTEM SET fsync = off; - ALTER SYSTEM SET full_page_writes = off; - ALTER SYSTEM SET synchronous_commit = off; - SELECT pg_reload_conf(); - SQL - psql --dbname postgres --tuples-only --no-align --command \ - "SELECT bool_and(setting = 'off') FROM pg_settings WHERE name IN ('fsync', 'full_page_writes', 'synchronous_commit')" \ - | grep --fixed-strings --line-regexp t - - name: Install cargo-nextest - uses: taiki-e/install-action@0fd46367812ee04360509b4169d9f659d6892bb2 # v2.79.15 - with: - tool: cargo-nextest@0.9.136 - - name: Download backend test archive - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 - with: - name: desktop-e2e-relay - path: target/ci - - name: PostgreSQL-backed tests - env: - BUZZ_POSTGRES_ADMIN_URL: postgres://buzz:${{ env.BUZZ_TEST_POSTGRES_PASSWORD }}@localhost:5432/postgres - PGPASSWORD: ${{ env.BUZZ_TEST_POSTGRES_PASSWORD }} - run: | - scripts/postgres-test-run.sh \ - --archive-file target/ci/postgres-tests.tar.zst - - desktop-e2e-integration-shard: - name: Desktop E2E Integration (${{ matrix.shard }}/2) - runs-on: ubuntu-latest - timeout-minutes: 20 - needs: [changes, desktop-e2e-relay] - if: github.event_name == 'push' || needs.changes.outputs.desktop == 'true' || needs.changes.outputs.desktop-rust == 'true' || needs.changes.outputs.rust == 'true' - strategy: - fail-fast: false - matrix: - shard: [1, 2] - permissions: - contents: read - steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 - - name: Start integration services - run: | - for attempt in 1 2 3; do - if docker compose up -d postgres redis minio minio-init; then - break - fi - if [ "$attempt" -eq 3 ]; then - echo "docker compose up failed after 3 attempts" >&2 - exit 1 - fi - echo "docker compose up failed (attempt $attempt), retrying in $((attempt * 5))s..." >&2 - sleep $((attempt * 5)) - done - - name: Get pnpm store directory - id: pnpm-cache - run: echo "STORE_PATH=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT" - - name: Restore pnpm store cache - uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5 - with: - path: ${{ steps.pnpm-cache.outputs.STORE_PATH }} - key: pnpm-${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }} - restore-keys: pnpm-${{ runner.os }}- - - name: Install desktop dependencies - run: just desktop-install-ci - - name: Get Playwright version - id: pw-version - run: echo "version=$(cd desktop && node -e "console.log(require('@playwright/test/package.json').version)")" >> "$GITHUB_OUTPUT" - - name: Restore Playwright browser cache - id: playwright-cache - uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5 - with: - path: ${{ env.PLAYWRIGHT_BROWSERS_PATH }} - key: playwright-${{ runner.os }}-${{ steps.pw-version.outputs.version }} - - name: Install Playwright Chromium - if: steps.playwright-cache.outputs.cache-hit != 'true' - run: cd desktop && pnpm exec playwright install chromium - - name: Install Playwright system dependencies - run: cd desktop && pnpm exec playwright install-deps chromium - - name: Save Playwright browser cache - if: steps.playwright-cache.outputs.cache-hit != 'true' && github.event_name == 'push' && matrix.shard == 1 - uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5 - with: - path: ${{ env.PLAYWRIGHT_BROWSERS_PATH }} - key: playwright-${{ runner.os }}-${{ steps.pw-version.outputs.version }} - - name: Desktop E2E build - run: pnpm -C desktop build:e2e - - name: Wait for integration services - run: | - wait_healthy() { - local service="$1" - local container="$2" - for attempt in $(seq 1 60); do - status=$(docker inspect --format='{{.State.Health.Status}}' "${container}" 2>/dev/null || echo "not_found") - if [ "${status}" = "healthy" ]; then - echo "${service} is healthy" - return 0 - fi - sleep 2 - done - docker logs "${container}" || true - return 1 - } - wait_healthy "Postgres" "buzz-postgres" - wait_healthy "Redis" "buzz-redis" - wait_healthy "MinIO" "buzz-minio" - - name: Download relay binary - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 - with: - name: desktop-e2e-relay - path: target/ci - - name: Apply schema and seed deployment community - # MT: the relay resolves each request's tenant from the communities host - # map and fails closed on an unmapped host. The channel reconciler binds - # the deployment community ONCE at boot (outside its retry loop) and - # exits permanently on an unmapped host, so the 'localhost:3000' - # community MUST exist before the relay starts — the retry loop only - # handles late-seeded channels, not a late-seeded community. The relay - # migrates at boot via BUZZ_AUTO_MIGRATE, but that's too late for the - # pre-boot seed, so apply the schema here first (then drop AUTO_MIGRATE - # below). lower(host) is the unique index → ON CONFLICT target. psql - # isn't on PATH in hermit → exec into the buzz-postgres container. - env: - PGHOST: localhost - PGPORT: "5432" - PGUSER: buzz - PGPASSWORD: buzz_dev - PGDATABASE: buzz - # Use the already-running docker postgres for desired-state planning instead of - # downloading an embedded Postgres from Maven Central (transient-fetch flake source). - PGSCHEMA_PLAN_HOST: localhost - PGSCHEMA_PLAN_PORT: "5432" - PGSCHEMA_PLAN_DB: buzz - PGSCHEMA_PLAN_USER: buzz - PGSCHEMA_PLAN_PASSWORD: buzz_dev - run: | - ./bin/pgschema apply --file schema/schema.sql --auto-approve - docker exec -i -e PGPASSWORD=buzz_dev buzz-postgres \ - psql -U buzz -d buzz -v ON_ERROR_STOP=1 < scripts/reconcile-schema-after-pgschema.sql - docker exec -e PGPASSWORD=buzz_dev buzz-postgres \ - psql -U buzz -d buzz -qtA -c " - INSERT INTO communities (id, host) - VALUES ('00000000-0000-4000-8000-00000000c0de', 'localhost:3000') - ON CONFLICT (lower(host)) DO NOTHING - ;" - - name: Start relay - run: | - chmod +x ./target/ci/buzz-relay - nohup env \ - DATABASE_URL="postgres://buzz:${BUZZ_TEST_POSTGRES_PASSWORD}@localhost:5432/buzz" \ - REDIS_URL=redis://localhost:6379 \ - RELAY_URL=ws://localhost:3000 \ - BUZZ_BIND_ADDR=0.0.0.0:3000 \ - BUZZ_RELAY_PRIVATE_KEY="$(openssl rand -hex 32)" \ - BUZZ_REQUIRE_AUTH_TOKEN=false \ - BUZZ_RECONCILE_CHANNELS=true \ - BUZZ_RATE_LIMIT_HUMAN_MESSAGES_PER_MIN=100000 \ - BUZZ_RATE_LIMIT_HUMAN_API_CALLS_PER_MIN=100000 \ - BUZZ_RATE_LIMIT_HUMAN_WS_EVENTS_PER_SEC=10000 \ - BUZZ_GIT_PROBE_WRITERS=8 \ - SPROUT_REMINDER_SCHEDULER_INTERVAL_SECS=1 \ - ./target/ci/buzz-relay > /tmp/buzz-relay.log 2>&1 & - echo $! > /tmp/buzz-relay.pid - for attempt in $(seq 1 60); do - if ! kill -0 "$(cat /tmp/buzz-relay.pid)" 2>/dev/null; then - cat /tmp/buzz-relay.log - exit 1 - fi - status_code=$(curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:3000/_readiness || true) - if [ "${status_code}" = "200" ]; then - exit 0 - fi - sleep 1 - done - cat /tmp/buzz-relay.log - exit 1 - - name: Seed desktop e2e data - run: bash scripts/setup-desktop-test-data.sh - - name: Desktop relay-backed e2e - run: cd desktop && pnpm exec playwright test --project=integration --shard=${{ matrix.shard }}/2 - - name: Summarize flaky tests - if: ${{ !cancelled() }} - run: node scripts/summarize-flaky-tests.mjs playwright-report.json "Desktop E2E Integration (${{ matrix.shard }}/2)" - working-directory: desktop - - name: Upload desktop integration artifacts - if: ${{ !cancelled() }} - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 - with: - name: desktop-e2e-integration-artifacts-${{ matrix.shard }} - path: | - desktop/playwright-report - desktop/playwright-report.json - desktop/test-results - /tmp/buzz-relay.log - if-no-files-found: ignore - retention-days: 7 - - name: Save pnpm store cache - if: github.event_name == 'push' - uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5 - with: - path: ${{ steps.pnpm-cache.outputs.STORE_PATH }} - key: pnpm-${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }} - - desktop-e2e-integration: - name: Desktop E2E Integration - runs-on: ubuntu-latest - timeout-minutes: 5 - needs: [changes, desktop-e2e-integration-shard] - if: always() && (github.event_name == 'push' || needs.changes.outputs.desktop == 'true' || needs.changes.outputs.desktop-rust == 'true' || needs.changes.outputs.rust == 'true') - permissions: - contents: read - steps: - - name: Check integration shards - run: | - if [ "${{ needs.desktop-e2e-integration-shard.result }}" != "success" ]; then - echo "Desktop E2E Integration shards finished with: ${{ needs.desktop-e2e-integration-shard.result }}" - exit 1 - fi - echo "Desktop E2E Integration shards passed" - - backend-integration: - name: Backend Integration (relay e2e) - runs-on: ubuntu-latest - timeout-minutes: 20 - needs: [changes, desktop-e2e-relay] - if: github.event_name == 'push' || needs.changes.outputs.rust == 'true' - permissions: - contents: read - steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 - - name: Install cargo-nextest - uses: taiki-e/install-action@0fd46367812ee04360509b4169d9f659d6892bb2 # v2.79.15 - with: - tool: cargo-nextest@0.9.136 - - name: Start integration services - run: | - for attempt in 1 2 3; do - if docker compose up -d postgres redis minio minio-init; then - break - fi - if [ "$attempt" -eq 3 ]; then - echo "docker compose up failed after 3 attempts" >&2 - exit 1 - fi - echo "docker compose up failed (attempt $attempt), retrying in $((attempt * 5))s..." >&2 - sleep $((attempt * 5)) - done - - name: Wait for integration services - run: | - wait_healthy() { - local service="$1" - local container="$2" - for attempt in $(seq 1 60); do - status=$(docker inspect --format='{{.State.Health.Status}}' "${container}" 2>/dev/null || echo "not_found") - if [ "${status}" = "healthy" ]; then - echo "${service} is healthy" - return 0 - fi - sleep 2 - done - docker logs "${container}" || true - return 1 - } - wait_healthy "Postgres" "buzz-postgres" - wait_healthy "Redis" "buzz-redis" - wait_healthy "MinIO" "buzz-minio" - - name: Download relay artifacts - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 - with: - name: desktop-e2e-relay - path: target/ci - - name: Apply schema and seed deployment community - # MT: the relay resolves each request's tenant from the communities host - # map and fails closed on an unmapped host. The reminder scheduler binds - # the deployment community ONCE at boot and exits permanently on an - # unmapped host (no retry, unlike the channel reconciler), so the - # 'localhost:3000' community MUST exist before the relay starts — seeding - # after boot leaves the scheduler dead. The relay migrates at boot via - # BUZZ_AUTO_MIGRATE, but that's too late for the pre-boot seed, so apply - # the schema here first (then drop AUTO_MIGRATE below). lower(host) is the - # unique index → ON CONFLICT target. psql isn't on PATH in hermit → exec - # into the buzz-postgres container. - env: - PGHOST: localhost - PGPORT: "5432" - PGUSER: buzz - PGPASSWORD: buzz_dev - PGDATABASE: buzz - # Use the already-running docker postgres for desired-state planning instead of - # downloading an embedded Postgres from Maven Central (transient-fetch flake source). - PGSCHEMA_PLAN_HOST: localhost - PGSCHEMA_PLAN_PORT: "5432" - PGSCHEMA_PLAN_DB: buzz - PGSCHEMA_PLAN_USER: buzz - PGSCHEMA_PLAN_PASSWORD: buzz_dev - run: | - ./bin/pgschema apply --file schema/schema.sql --auto-approve - docker exec -i -e PGPASSWORD=buzz_dev buzz-postgres \ - psql -U buzz -d buzz -v ON_ERROR_STOP=1 < scripts/reconcile-schema-after-pgschema.sql - docker exec -e PGPASSWORD=buzz_dev buzz-postgres \ - psql -U buzz -d buzz -qtA -c " - INSERT INTO communities (id, host) - VALUES ('00000000-0000-4000-8000-00000000c0de', 'localhost:3000') - ON CONFLICT (lower(host)) DO NOTHING - ;" - - name: Workflow message provenance unit tests - # The relay's workflow_sink suite is not selected by the infra-free - # unit job. Its ignored database cases run in the isolated PostgreSQL - # lane; keep the pure provenance cases covered here without duplication. - run: | - cargo nextest run \ - --archive-file target/ci/backend-integration-tests.tar.zst \ - -E 'package(buzz-relay) and test(/workflow_sink/)' - - name: Start relay - run: | - chmod +x ./target/ci/buzz-relay - nohup env \ - DATABASE_URL="postgres://buzz:${BUZZ_TEST_POSTGRES_PASSWORD}@localhost:5432/buzz" \ - REDIS_URL=redis://localhost:6379 \ - RELAY_URL=ws://localhost:3000 \ - BUZZ_BIND_ADDR=0.0.0.0:3000 \ - BUZZ_RELAY_PRIVATE_KEY="$(openssl rand -hex 32)" \ - BUZZ_REQUIRE_AUTH_TOKEN=false \ - BUZZ_RECONCILE_CHANNELS=true \ - BUZZ_GIT_PROBE_WRITERS=8 \ - SPROUT_REMINDER_SCHEDULER_INTERVAL_SECS=1 \ - ./target/ci/buzz-relay > /tmp/buzz-relay.log 2>&1 & - echo $! > /tmp/buzz-relay.pid - for attempt in $(seq 1 60); do - if ! kill -0 "$(cat /tmp/buzz-relay.pid)" 2>/dev/null; then - cat /tmp/buzz-relay.log - exit 1 - fi - status_code=$(curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:3000/_readiness || true) - if [ "${status_code}" = "200" ]; then - exit 0 - fi - sleep 1 - done - cat /tmp/buzz-relay.log - exit 1 - - name: NIP-ER reminder e2e - # Feature e2e for NIP-ER (Event Reminders, kind:30300): write-path - # validation, author-only read filtering, and scheduler delivery against - # a live relay. The schema-drift / migration-version guarantee is owned - # by the buzz-db migration.rs unit tests, not this suite. - run: | - cargo nextest run \ - --archive-file target/ci/backend-integration-tests.tar.zst \ - -E 'binary(e2e_event_reminder)' \ - --run-ignored ignored-only - env: - RELAY_URL: ws://localhost:3000 - - name: Upload relay log - if: failure() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 - with: - name: backend-integration-relay-log - path: /tmp/buzz-relay.log - if-no-files-found: ignore - - relay-e2e: - name: Relay E2E - runs-on: ubuntu-latest - timeout-minutes: 20 - needs: [changes, desktop-e2e-relay] - if: github.event_name == 'push' || needs.changes.outputs.rust == 'true' - permissions: - contents: read - steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 - - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2.9.1 - with: - save-if: ${{ github.event_name != 'pull_request' }} - # Reuse the relay + git-credential-nostr built by Desktop E2E Relay - # instead of compiling them a second time. - - name: Download relay binary - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 - with: - name: desktop-e2e-relay - path: target/ci - - name: Start relay - run: | - chmod +x ./target/ci/buzz-relay ./target/ci/git-credential-nostr - ./scripts/start-relay-for-tests.sh --no-build - - name: Relay E2E tests - run: | - cargo test -p buzz-test-client --test e2e_persona --test e2e_team_catalog --test e2e_nostr_interop --test e2e_project -- --ignored --nocapture - cargo test -p buzz-test-client --test e2e_relay invite -- --ignored --nocapture - cargo test -p buzz-test-client --test e2e_relay nip43_membership_snapshots_are_rejected -- --ignored --nocapture - env: - RELAY_URL: ws://localhost:3000 - GIT_CREDENTIAL_NOSTR_BIN: ${{ github.workspace }}/target/ci/git-credential-nostr - - name: Media read-auth e2e - # Reads require kind:24242 `t=get` auth, so these binaries are the only - # coverage that a real relay rejects bare reads and honours host- and - # hash-scoped tokens. They were #[ignore]d and selected by no CI job, so - # the lane never ran; select it here, where MinIO and the seeded - # 'localhost:3000' community already exist. - # --no-fail-fast: without it cargo stops after the first failing binary, - # so one broken case hides every later binary's result. - run: | - cargo test -p buzz-test-client --no-fail-fast --test e2e_media --test e2e_media_extended --test e2e_media_video -- --ignored --nocapture - env: - RELAY_URL: ws://localhost:3000 - RELAY_HTTP_URL: http://localhost:3000 - - name: Upload relay logs - if: failure() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 - with: - name: relay-e2e-artifacts - path: /tmp/buzz-relay.log - if-no-files-found: ignore - - web: - name: Web - runs-on: ubuntu-latest - timeout-minutes: 15 - needs: [changes] - if: github.event_name == 'push' || needs.changes.outputs.web == 'true' - permissions: - contents: read - steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - with: - fetch-depth: 2 - - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 - - name: Get pnpm store directory - id: pnpm-cache - run: echo "STORE_PATH=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT" - - name: Restore pnpm store cache - uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5 - with: - path: ${{ steps.pnpm-cache.outputs.STORE_PATH }} - key: pnpm-${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }} - restore-keys: pnpm-${{ runner.os }}- - - name: Install dependencies - run: pnpm install --frozen-lockfile - - name: Web lint and format - run: just web-check - - name: Web build - run: just web-build - - name: Save pnpm store cache - if: github.event_name == 'push' - uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5 - with: - path: ${{ steps.pnpm-cache.outputs.STORE_PATH }} - key: pnpm-${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }} - - mobile: - name: Mobile - runs-on: ubuntu-latest - timeout-minutes: 30 - needs: [changes] - if: github.event_name == 'push' || needs.changes.outputs.mobile == 'true' - permissions: - contents: read - steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - with: - fetch-depth: 2 - - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 - - name: Compute Hermit cache key - id: hermit-bin-hash - run: | - hash="$(find ./bin ! -type d | sort | xargs openssl sha256 | openssl sha256 -r | cut -d' ' -f1)" - echo "hash=$hash" >> "$GITHUB_OUTPUT" - - name: Restore Hermit package cache - id: hermit-cache - uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5 - with: - path: ~/.cache/hermit/pkg - key: ${{ runner.os }}-hermit-cache-${{ steps.hermit-bin-hash.outputs.hash }} - restore-keys: ${{ runner.os }}-hermit-cache- - - name: Prime Flutter SDK - run: flutter --version - - name: Save Hermit package cache - if: always() && steps.hermit-cache.outputs.cache-hit != 'true' - uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5 - continue-on-error: true - with: - path: ~/.cache/hermit/pkg - key: ${{ runner.os }}-hermit-cache-${{ steps.hermit-bin-hash.outputs.hash }} - - name: Restore pub cache - id: pub-cache - uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5 - with: - path: ~/.pub-cache - key: pub-${{ runner.os }}-${{ hashFiles('mobile/pubspec.lock') }} - restore-keys: pub-${{ runner.os }}- - - name: Install dependencies - run: cd mobile && flutter pub get - - name: Save pub cache - if: always() && steps.pub-cache.outputs.cache-hit != 'true' - uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5 - continue-on-error: true - with: - path: ~/.pub-cache - key: pub-${{ runner.os }}-${{ hashFiles('mobile/pubspec.lock') }} - - name: Format check - run: cd mobile && dart format --output=none --set-exit-if-changed . - - name: Analyze - run: cd mobile && flutter analyze - - name: Test - run: cd mobile && flutter test - - name: Build Android debug APK - run: just mobile-build-android - - mobile-swift: - name: Mobile Swift - runs-on: macos-latest - timeout-minutes: 30 - needs: [changes] - if: needs.changes.outputs.mobile == 'true' - steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 - - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 - - name: Install Flutter dependencies - run: cd mobile && flutter pub get - - name: Build - run: swift build --package-path mobile/ios/BuzzPushKit - - name: Build release - run: swift build -c release --package-path mobile/ios/BuzzPushKit - - name: Test - run: swift test --package-path mobile/ios/BuzzPushKit - - name: Build complete unsigned iOS release - run: cd mobile && flutter build ios --release --no-codesign --no-pub - security: - name: Security - runs-on: ubuntu-latest - timeout-minutes: 20 - needs: [changes] - if: github.event_name == 'push' || needs.changes.outputs.rust == 'true' - permissions: - contents: read - steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 - - name: Dependency policy - run: cargo-deny check - dead-token-guard: name: Dead Token Reference Guard runs-on: ubuntu-latest @@ -1054,247 +138,272 @@ jobs: fi echo "No dead token references found." - server-cross-compile: - name: Server Cross-Compile - runs-on: ubuntu-latest - timeout-minutes: 30 + rust: + name: Rust + needs: [changes] + if: github.event_name == 'push' || needs.changes.outputs.rust == 'true' || needs.changes.outputs.desktop-rust == 'true' + uses: ./.github/workflows/_ci-rust.yml + with: + rust: ${{ needs.changes.outputs.rust == 'true' }} + desktop_rust: ${{ needs.changes.outputs.desktop-rust == 'true' }} + lane: required + + rust-cross-compile-domain: + name: Rust Cross-Compile needs: [changes] if: github.event_name == 'push' || needs.changes.outputs.rust == 'true' - permissions: - contents: read - strategy: - fail-fast: false - matrix: - target: - - x86_64-unknown-linux-musl - - aarch64-unknown-linux-musl - steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 - - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2.9.1 - with: - key: cross-${{ matrix.target }} - save-if: ${{ github.event_name != 'pull_request' }} - - name: Install cross - uses: taiki-e/install-action@0fd46367812ee04360509b4169d9f659d6892bb2 # v2.79.15 - with: - tool: cross@0.2.5 - - name: Build server binaries - env: - TARGET: ${{ matrix.target }} - # PRs: compile + build-script gate only (no codegen/link). Main: full link gate. - CARGO_CMD: ${{ github.event_name == 'pull_request' && 'check' || 'build' }} - run: | - cross "$CARGO_CMD" --release --target "$TARGET" \ - -p buzz-relay \ - -p buzz-acp \ - -p buzz-agent \ - -p buzz-dev-mcp \ - -p git-credential-nostr \ - -p git-sign-nostr + uses: ./.github/workflows/_ci-rust.yml + with: + rust: ${{ needs.changes.outputs.rust == 'true' }} + desktop_rust: ${{ needs.changes.outputs.desktop-rust == 'true' }} + lane: cross-compile - desktop-windows-build: - name: Desktop Windows Build - runs-on: windows-latest - timeout-minutes: 20 + desktop-domain: + name: Desktop Domain needs: [changes] if: github.event_name == 'push' || needs.changes.outputs.desktop == 'true' || needs.changes.outputs.desktop-rust == 'true' || needs.changes.outputs.rust == 'true' - permissions: - contents: read + uses: ./.github/workflows/_ci-desktop.yml + with: + rust: ${{ needs.changes.outputs.rust == 'true' }} + desktop: ${{ needs.changes.outputs.desktop == 'true' }} + desktop_rust: ${{ needs.changes.outputs.desktop-rust == 'true' }} + + relay-artifacts-domain: + name: Relay Artifact Producer + needs: [changes] + if: github.event_name == 'push' || needs.changes.outputs.desktop == 'true' || needs.changes.outputs.desktop-rust == 'true' || needs.changes.outputs.rust == 'true' + uses: ./.github/workflows/_ci-relay.yml + with: + rust: ${{ needs.changes.outputs.rust == 'true' }} + desktop: ${{ needs.changes.outputs.desktop == 'true' }} + desktop_rust: ${{ needs.changes.outputs.desktop-rust == 'true' }} + lane: artifacts + + postgres-domain: + name: PostgreSQL Domain + needs: [changes, relay-artifacts-domain] + if: github.event_name == 'push' || needs.changes.outputs.rust == 'true' + uses: ./.github/workflows/_ci-relay.yml + with: + rust: ${{ needs.changes.outputs.rust == 'true' }} + desktop: ${{ needs.changes.outputs.desktop == 'true' }} + desktop_rust: ${{ needs.changes.outputs.desktop-rust == 'true' }} + lane: postgres + + desktop-macos-domain: + name: Desktop macOS Domain + needs: [changes] + if: github.event_name == 'push' || needs.changes.outputs.desktop == 'true' || needs.changes.outputs.desktop-rust == 'true' || needs.changes.outputs.rust == 'true' + uses: ./.github/workflows/_ci-desktop-macos.yml + with: + rust: ${{ needs.changes.outputs.rust == 'true' }} + desktop: ${{ needs.changes.outputs.desktop == 'true' }} + desktop_rust: ${{ needs.changes.outputs.desktop-rust == 'true' }} + + relay-domain: + name: Relay and PostgreSQL + needs: [changes, relay-artifacts-domain] + if: github.event_name == 'push' || needs.changes.outputs.desktop == 'true' || needs.changes.outputs.desktop-rust == 'true' || needs.changes.outputs.rust == 'true' + uses: ./.github/workflows/_ci-relay.yml + with: + rust: ${{ needs.changes.outputs.rust == 'true' }} + desktop: ${{ needs.changes.outputs.desktop == 'true' }} + desktop_rust: ${{ needs.changes.outputs.desktop-rust == 'true' }} + lane: required + + clients: + name: Clients + needs: [changes] + if: github.event_name == 'push' || needs.changes.outputs.web == 'true' || needs.changes.outputs.mobile == 'true' + uses: ./.github/workflows/_ci-clients.yml + with: + web: ${{ needs.changes.outputs.web == 'true' }} + mobile: ${{ needs.changes.outputs.mobile == 'true' }} + lane: required + + mobile-swift-domain: + name: Mobile Swift Domain + needs: [changes] + if: needs.changes.outputs.mobile == 'true' + uses: ./.github/workflows/_ci-clients.yml + with: + web: ${{ needs.changes.outputs.web == 'true' }} + mobile: ${{ needs.changes.outputs.mobile == 'true' }} + lane: mobile-swift + + security-domain: + name: Security Domain + needs: [changes] + if: github.event_name == 'push' || needs.changes.outputs.rust == 'true' + uses: ./.github/workflows/_ci-security.yml + with: + rust: ${{ needs.changes.outputs.rust == 'true' }} + + rust-lint: + name: Rust Lint + if: always() && needs.changes.result == 'success' && (github.event_name == 'push' || needs.changes.outputs.rust == 'true' || needs.changes.outputs.desktop-rust == 'true') + needs: [changes, rust] + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: {} steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 - with: - node-version: 24.14.1 - package-manager-cache: false - - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0 - with: - version: 11.4.0 - - name: Install desktop dependencies - shell: bash - run: pnpm install --frozen-lockfile - - name: Build both protected-feature selections - shell: pwsh - run: | - Remove-Item Env:VITE_BUZZ_BESTIE -ErrorAction SilentlyContinue - pnpm -C desktop build - if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - $env:VITE_BUZZ_BESTIE = "1" - pnpm -C desktop build - if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + - name: Check Rust Lint result + env: + RESULT: ${{ needs.rust.outputs.rust_lint_result }} + run: test "$RESULT" = success + + unit-tests: + name: Unit Tests + if: always() && needs.changes.result == 'success' && (github.event_name == 'push' || needs.changes.outputs.rust == 'true') + needs: [changes, rust] + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: {} + steps: + - name: Check Unit Tests result + env: + RESULT: ${{ needs.rust.outputs.unit_tests_result }} + run: test "$RESULT" = success windows-rust: name: Windows Rust (x86_64-pc-windows-msvc) - runs-on: windows-latest - # Windows runners are slow and this compiles the workspace + Tauri crate - # cold across four steps; budget generously. - timeout-minutes: 45 - needs: [changes] - if: github.event_name == 'push' || needs.changes.outputs.rust == 'true' || needs.changes.outputs.desktop-rust == 'true' - permissions: - contents: read - env: - TARGET: x86_64-pc-windows-msvc + if: always() && needs.changes.result == 'success' && (github.event_name == 'push' || needs.changes.outputs.rust == 'true' || needs.changes.outputs.desktop-rust == 'true') + needs: [changes, rust] + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: {} steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - # MSVC needs windows.h (aws-lc-sys et al.), so this runs on a real Windows - # runner — hermit, used by the Linux jobs, does not provide MSVC. The - # toolchain (1.95.0 + clippy via profile = default) comes from the - # repo-root rust-toolchain.toml, which the runner's preinstalled rustup - # honors on demand; the host triple already is x86_64-pc-windows-msvc. - - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2.9.1 - with: - workspaces: | - . - desktop/src-tauri - key: windows-msvc - save-if: ${{ github.event_name != 'pull_request' }} - # Tauri validates externalBin at compile time, so the Tauri-crate steps - # below fail without these stubs. Mirrors scripts/bundle-sidecars.sh's - # Windows naming (binaries/-.exe); empty files suffice for a - # type-check since nothing executes them. - - name: Create sidecar placeholders - shell: bash - run: | - mkdir -p desktop/src-tauri/binaries - for bin in buzz-acp buzz-agent buzz-dev-mcp git-credential-nostr buzz; do - touch "desktop/src-tauri/binaries/${bin}-${TARGET}.exe" - done - - name: Clippy (workspace) - run: cargo clippy --workspace --all-targets --target $env:TARGET -- -D warnings - - name: Check (workspace) - run: cargo check --workspace --all-targets --target $env:TARGET - - name: Test (buzz-dev-mcp) - # The Windows-only bash resolver lives in buzz-dev-mcp; its unit tests - # only gate if this crate is tested ON Windows. - # Serial: windows_resolver_tests mutate process-global env - # (BUZZ_SHELL/GIT_BASH/SystemRoot) that SharedState::new reads. - run: cargo test -p buzz-dev-mcp --target $env:TARGET -- --test-threads=1 - - name: Test (buzz-agent auth coordinator) - # The auth coordinator single-flights on an OS advisory lock, which is - # LockFileEx on Windows; this integration suite drives real second - # processes on the same lock file, so it only exercises the Windows - # lock runtime if it runs ON Windows. Every other job compiles it but - # never executes it. Tests exercised on Windows: lock serialization - # (two coordinators race for the same key), cooldown sidecar sharing - # across processes, attempt-sidecar adoption (UserInitiated waiter - # adopts a predecessor's denial), and the in-process single-flight for - # same-key coalescing. Tests that are UNIX-ONLY and NOT executed here: - # crash-release (flock drop on SIGKILL, guarded by #[cfg(unix)]) and - # cross-process cache success/race (on-disk token handoff, also - # #[cfg(unix)]). - run: cargo test -p buzz-agent --target $env:TARGET --test databricks_auth_coordinator - # Smoke-test the new host-prereq contract: Git for Windows (which provides - # bash) is available on the runner, a shell command round-trips, and bash - # does NOT resolve from System32 (so WSL's launcher is never picked up). - # windows-latest runners have Git for Windows pre-installed; the unit tests - # above exercise the MCP resolver itself. This step verifies the host env. - - name: Smoke-test host Git Bash prereq (host env check) - shell: bash - run: | - set -euo pipefail - # Git for Windows ships bash.exe under its bin/ directory; confirm it - # resolves from the standard location the runtime resolver probes first. - bash_path=$(command -v bash 2>/dev/null || true) - [[ -n "$bash_path" ]] || { echo "ERROR: bash not found on PATH — host Git for Windows missing" >&2; exit 1; } - echo "Resolved bash: $bash_path" - [[ "$bash_path" != *System32* ]] || { echo "ERROR: resolved bash is WSL's System32 launcher" >&2; exit 1; } - - # Run a basic pipeline through the resolved bash (same invocation the - # agent uses: bash -c '...'). - out=$(bash -c 'echo hello | tr a-z A-Z') - [[ "$out" == "HELLO" ]] || { echo "bash pipeline failed: got '$out'" >&2; exit 1; } - - # Confirm git itself works — agents run git commands frequently. - git --version - repo=$(mktemp -d) - cd "$repo" - git init -q - git -c user.name=ci -c user.email=ci@example.com commit -q --allow-empty -m smoke - git log -1 --format=%s | grep -qx smoke - echo "Host bash resolved and functional; git commit round-trip passed" - - name: Check (Tauri crate) - run: cargo check --manifest-path desktop/src-tauri/Cargo.toml --workspace --all-targets --target $env:TARGET + - name: Check Windows Rust result env: - CMAKE_POLICY_VERSION_MINIMUM: "3.5" - - name: Test (Tauri crate) - run: cargo test --manifest-path desktop/src-tauri/Cargo.toml --target $env:TARGET + RESULT: ${{ needs.rust.outputs.windows_rust_result }} + run: test "$RESULT" = success + + desktop: + name: Desktop + if: always() && needs.changes.result == 'success' && (github.event_name == 'push' || needs.changes.outputs.desktop == 'true' || needs.changes.outputs.desktop-rust == 'true' || needs.changes.outputs.rust == 'true') + needs: [changes, desktop-domain] + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: {} + steps: + - name: Check Desktop result env: - CMAKE_POLICY_VERSION_MINIMUM: "3.5" + RESULT: ${{ needs.desktop-domain.outputs.desktop_result }} + run: test "$RESULT" = success desktop-build-macos: name: Desktop Build (macOS) - runs-on: macos-latest - timeout-minutes: 45 - needs: [changes] - if: github.event_name == 'push' || needs.changes.outputs.desktop == 'true' || needs.changes.outputs.desktop-rust == 'true' || needs.changes.outputs.rust == 'true' - permissions: - contents: read + if: always() && needs.changes.result == 'success' && (github.event_name == 'push' || needs.changes.outputs.desktop == 'true' || needs.changes.outputs.desktop-rust == 'true' || needs.changes.outputs.rust == 'true') + needs: [changes, desktop-macos-domain] + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: {} steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 - - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2.9.1 - with: - workspaces: desktop/src-tauri - save-if: ${{ github.event_name != 'pull_request' }} - - name: Install desktop dependencies - run: just desktop-install-ci - - name: Create sidecar placeholders - run: | - TARGET=$(rustc -vV | sed -n 's|host: ||p') - mkdir -p desktop/src-tauri/binaries - touch "desktop/src-tauri/binaries/buzz-acp-$TARGET" - touch "desktop/src-tauri/binaries/buzz-agent-$TARGET" - touch "desktop/src-tauri/binaries/buzz-backend-kubernetes-$TARGET" - touch "desktop/src-tauri/binaries/buzz-dev-mcp-$TARGET" - touch "desktop/src-tauri/binaries/git-credential-nostr-$TARGET" - touch "desktop/src-tauri/binaries/buzz-$TARGET" - # Mesh rev is derived from Cargo.lock so a dependency bump needs no - # lockstep edit here; the cache key tracks it automatically. - - name: Resolve mesh-llm rev - id: mesh_rev - run: | - set -euo pipefail - REV=$(python3 -c 'import tomllib; d=tomllib.load(open("Cargo.lock", "rb")); p=next(p for p in d["package"] if p["name"] == "mesh-llm-sdk"); print(p["source"].rsplit("#", 1)[1])') - [[ -n "$REV" ]] || { echo "::error::could not resolve mesh-llm rev from Cargo.lock"; exit 1; } - echo "rev=$REV" >> "$GITHUB_OUTPUT" - echo "short=${REV:0:7}" >> "$GITHUB_OUTPUT" - - name: Restore mesh llama build cache - id: llama_cache - uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5 - with: - path: ${{ github.workspace }}/.cache/mesh-llama - key: mesh-llama-${{ runner.os }}-metal-${{ steps.mesh_rev.outputs.rev }} - - name: Build mesh llama native libraries - if: steps.llama_cache.outputs.cache-hit != 'true' + - name: Check Desktop Build (macOS) result env: - MESH_REV_SHORT: ${{ steps.mesh_rev.outputs.short }} - run: | - set -euo pipefail - cargo fetch --manifest-path desktop/src-tauri/Cargo.toml - SHORT="$MESH_REV_SHORT" - MESH_ROOT=$(find "${CARGO_HOME:-$HOME/.cargo}/git/checkouts" -path "*/$SHORT" -type d -name "$SHORT" | head -1) - if [[ -z "$MESH_ROOT" ]]; then - echo "::error::mesh-llm checkout for $SHORT not found after cargo fetch" - exit 1 - fi - export LLAMA_STAGE_BACKEND=metal - export LLAMA_STAGE_BUILD_DIR="$GITHUB_WORKSPACE/.cache/mesh-llama/build-stage-abi-metal" - export CMAKE_OSX_DEPLOYMENT_TARGET=10.15 - "$MESH_ROOT/scripts/prepare-llama.sh" pinned - "$MESH_ROOT/scripts/build-llama.sh" -DCMAKE_OSX_DEPLOYMENT_TARGET=10.15 - - name: Save mesh llama build cache - if: steps.llama_cache.outputs.cache-hit != 'true' - uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5 - with: - path: ${{ github.workspace }}/.cache/mesh-llama - key: mesh-llama-${{ runner.os }}-metal-${{ steps.mesh_rev.outputs.rev }} - - name: Build Tauri app - run: cd desktop && pnpm tauri build + RESULT: ${{ needs.desktop-macos-domain.outputs.desktop_macos_result }} + run: test "$RESULT" = success + + desktop-e2e-relay: + name: Desktop E2E Relay + if: always() && needs.changes.result == 'success' && (github.event_name == 'push' || needs.changes.outputs.desktop == 'true' || needs.changes.outputs.desktop-rust == 'true' || needs.changes.outputs.rust == 'true') + needs: [changes, relay-artifacts-domain] + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: {} + steps: + - name: Check Desktop E2E Relay result env: - CMAKE_POLICY_VERSION_MINIMUM: "3.5" - MACOSX_DEPLOYMENT_TARGET: "10.15" - CMAKE_OSX_DEPLOYMENT_TARGET: "10.15" - LLAMA_STAGE_BACKEND: metal - LLAMA_STAGE_BUILD_DIR: ${{ github.workspace }}/.cache/mesh-llama/build-stage-abi-metal - SKIPPY_LLAMA_AUTO_BUILD: "0" + RESULT: ${{ needs.relay-artifacts-domain.outputs.desktop_e2e_relay_result }} + run: test "$RESULT" = success + + desktop-e2e-integration: + name: Desktop E2E Integration + if: always() && needs.changes.result == 'success' && needs.relay-artifacts-domain.result == 'success' && (github.event_name == 'push' || needs.changes.outputs.desktop == 'true' || needs.changes.outputs.desktop-rust == 'true' || needs.changes.outputs.rust == 'true') + needs: [changes, relay-artifacts-domain, relay-domain] + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: {} + steps: + - name: Check Desktop E2E Integration result + env: + RESULT: ${{ needs.relay-domain.outputs.desktop_e2e_integration_result }} + run: test "$RESULT" = success + + backend-integration: + name: Backend Integration (relay e2e) + if: always() && needs.changes.result == 'success' && needs.relay-artifacts-domain.result == 'success' && (github.event_name == 'push' || needs.changes.outputs.rust == 'true') + needs: [changes, relay-artifacts-domain, relay-domain] + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: {} + steps: + - name: Check Backend Integration result + env: + RESULT: ${{ needs.relay-domain.outputs.backend_integration_result }} + run: test "$RESULT" = success + + postgres-tests: + name: PostgreSQL Tests + if: always() && needs.changes.result == 'success' && needs.relay-artifacts-domain.result == 'success' && (github.event_name == 'push' || needs.changes.outputs.rust == 'true') + needs: [changes, relay-artifacts-domain, postgres-domain] + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: {} + steps: + - name: Check PostgreSQL Tests result + env: + RESULT: ${{ needs.postgres-domain.outputs.postgres_tests_result }} + run: test "$RESULT" = success + + relay-e2e: + name: Relay E2E + if: always() && needs.changes.result == 'success' && needs.relay-artifacts-domain.result == 'success' && (github.event_name == 'push' || needs.changes.outputs.rust == 'true') + needs: [changes, relay-artifacts-domain, relay-domain] + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: {} + steps: + - name: Check Relay E2E result + env: + RESULT: ${{ needs.relay-domain.outputs.relay_e2e_result }} + run: test "$RESULT" = success + + web: + name: Web + if: always() && needs.changes.result == 'success' && (github.event_name == 'push' || needs.changes.outputs.web == 'true') + needs: [changes, clients] + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: {} + steps: + - name: Check Web result + env: + RESULT: ${{ needs.clients.outputs.web_result }} + run: test "$RESULT" = success + + mobile: + name: Mobile + if: always() && needs.changes.result == 'success' && (github.event_name == 'push' || needs.changes.outputs.mobile == 'true') + needs: [changes, clients] + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: {} + steps: + - name: Check Mobile result + env: + RESULT: ${{ needs.clients.outputs.mobile_result }} + run: test "$RESULT" = success + + security: + name: Security + if: always() && needs.changes.result == 'success' && (github.event_name == 'push' || needs.changes.outputs.rust == 'true') + needs: [changes, security-domain] + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: {} + steps: + - name: Check Security result + env: + RESULT: ${{ needs.security-domain.outputs.security_result }} + run: test "$RESULT" = success diff --git a/scripts/test-ci-required-context-isolation.sh b/scripts/test-ci-required-context-isolation.sh new file mode 100755 index 0000000000..dc5c11708e --- /dev/null +++ b/scripts/test-ci-required-context-isolation.sh @@ -0,0 +1,83 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) +orchestrator="$repo_root/.github/workflows/ci.yml" +desktop_workflow="$repo_root/.github/workflows/_ci-desktop.yml" +macos_workflow="$repo_root/.github/workflows/_ci-desktop-macos.yml" +rust_workflow="$repo_root/.github/workflows/_ci-rust.yml" +relay_workflow="$repo_root/.github/workflows/_ci-relay.yml" +clients_workflow="$repo_root/.github/workflows/_ci-clients.yml" + +fail() { + echo "CI required-context isolation contract failed: $*" >&2 + exit 1 +} + +[[ -f "$macos_workflow" ]] || fail "missing _ci-desktop-macos.yml" + +extract_job() { + local job=$1 + local workflow=$2 + awk -v job="$job" ' + $0 == " " job ":" { found = 1 } + found && $0 ~ /^ [a-zA-Z0-9_-]+:$/ && $0 != " " job ":" { exit } + found { print } + ' "$workflow" +} + +desktop_call=$(extract_job desktop-domain "$orchestrator") +macos_call=$(extract_job desktop-macos-domain "$orchestrator") +rust_call=$(extract_job rust "$orchestrator") +rust_cross_compile_call=$(extract_job rust-cross-compile-domain "$orchestrator") +relay_call=$(extract_job relay-domain "$orchestrator") +relay_artifacts_call=$(extract_job relay-artifacts-domain "$orchestrator") +postgres_call=$(extract_job postgres-domain "$orchestrator") +clients_call=$(extract_job clients "$orchestrator") +mobile_swift_call=$(extract_job mobile-swift-domain "$orchestrator") +desktop_gate=$(extract_job desktop "$orchestrator") +macos_gate=$(extract_job desktop-build-macos "$orchestrator") + +[[ "$desktop_call" == *'uses: ./.github/workflows/_ci-desktop.yml'* ]] || + fail "Desktop Domain must call _ci-desktop.yml" +[[ "$macos_call" == *'uses: ./.github/workflows/_ci-desktop-macos.yml'* ]] || + fail "Desktop macOS domain must call _ci-desktop-macos.yml" +[[ "$desktop_gate" == *'needs: [changes, desktop-domain]'* ]] || + fail "Desktop required check must depend only on Desktop Domain" +[[ "$desktop_gate" == *'needs.desktop-domain.outputs.desktop_result'* ]] || + fail "Desktop required check must read the Desktop Domain result" +[[ "$macos_gate" == *'needs: [changes, desktop-macos-domain]'* ]] || + fail "Desktop Build (macOS) required check must depend only on the macOS domain" +[[ "$macos_gate" == *'needs.desktop-macos-domain.outputs.desktop_macos_result'* ]] || + fail "Desktop Build (macOS) required check must read the macOS domain result" + +[[ "$rust_call" == *'lane: required'* && "$rust_cross_compile_call" == *'lane: cross-compile'* ]] || + fail "required Rust checks and server cross-compiles must use isolated calls" +[[ "$relay_artifacts_call" == *'lane: artifacts'* && "$relay_call" == *'lane: required'* && "$postgres_call" == *'lane: postgres'* ]] || + fail "relay artifacts, required checks, and PostgreSQL tests must use isolated calls" +[[ "$relay_call" == *'needs: [changes, relay-artifacts-domain]'* ]] || + fail "required relay checks must wait for the artifact producer" +[[ "$postgres_call" == *'needs: [changes, relay-artifacts-domain]'* ]] || + fail "PostgreSQL tests must wait for the artifact producer" +[[ "$clients_call" == *'lane: required'* && "$mobile_swift_call" == *'lane: mobile-swift'* ]] || + fail "required client checks and Mobile Swift must use isolated calls" + +grep -Fq "inputs.lane == 'cross-compile'" "$rust_workflow" || + fail "Rust cross-compiles must be selected by their isolated lane" +grep -Fq "inputs.lane == 'postgres'" "$relay_workflow" || + fail "PostgreSQL tests must be selected by their isolated lane" +grep -Fq "inputs.lane == 'artifacts'" "$relay_workflow" || + fail "relay artifacts must be selected by their isolated lane" +grep -Fq "inputs.lane == 'mobile-swift'" "$clients_workflow" || + fail "Mobile Swift must be selected by its isolated lane" + +grep -Fq 'on:' "$macos_workflow" || fail "macOS workflow is missing its trigger" +grep -Fq ' workflow_call:' "$macos_workflow" || fail "macOS workflow must use workflow_call" +grep -Fq ' desktop-build-macos:' "$macos_workflow" || fail "macOS build job is missing" +grep -Fq 'desktop_macos_result:' "$macos_workflow" || fail "macOS result output is missing" + +if grep -Eq '^ desktop-build-macos:|desktop_macos_result:' "$desktop_workflow"; then + fail "Desktop Domain must not own the isolated macOS required check" +fi + +echo "CI required-context isolation contract passed" diff --git a/scripts/test-rust-cache-contract-regressions.sh b/scripts/test-rust-cache-contract-regressions.sh index 708c26e77b..0275b87341 100755 --- a/scripts/test-rust-cache-contract-regressions.sh +++ b/scripts/test-rust-cache-contract-regressions.sh @@ -139,7 +139,7 @@ YAML run_contract >/dev/null rm -rf "$tmp/.github/actions" "$tmp/.github/workflows/new-cache-user.yaml" -python3 - "$tmp/.github/workflows/ci.yml" <<'PY' +python3 - "$tmp/.github/workflows/_ci-rust.yml" <<'PY' import pathlib import sys diff --git a/scripts/test-rust-cache-contract.sh b/scripts/test-rust-cache-contract.sh index 30abd02659..cf69c8aadc 100755 --- a/scripts/test-rust-cache-contract.sh +++ b/scripts/test-rust-cache-contract.sh @@ -91,9 +91,9 @@ cache_uses.each do |path, location, ref| end end -ci_path = root / ".github" / "workflows" / "ci.yml" -ci = workflows.fetch(ci_path) { load_workflow.call(ci_path) } -jobs = ci["jobs"] +rust_ci_path = root / ".github" / "workflows" / "_ci-rust.yml" +rust_ci = workflows.fetch(rust_ci_path) { load_workflow.call(rust_ci_path) } +jobs = rust_ci["jobs"] unit_tests = jobs.is_a?(Hash) ? jobs["unit-tests"] : nil abort "unit-tests job missing" unless unit_tests.is_a?(Hash) steps = unit_tests["steps"] From 2af9773d6271ce62cbe5dfbd28fbe5dd79372465 Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Wed, 2 Sep 2026 13:12:25 -0400 Subject: [PATCH 02/13] fix(acp): replace real user name in base prompt mention example (#7250) Replace the real user name in the shared ACP mention guidance with the fictional `Alice Smith` example. Preserve the exact-display-name and no-inference instructions while avoiding prompt priming from a real user identity. Signed-off-by: Will Pfleger Signed-off-by: Alia Co-authored-by: Alia --- crates/buzz-acp/src/base_prompt.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/buzz-acp/src/base_prompt.md b/crates/buzz-acp/src/base_prompt.md index 6cee0b603d..7e90b07b6a 100644 --- a/crates/buzz-acp/src/base_prompt.md +++ b/crates/buzz-acp/src/base_prompt.md @@ -50,7 +50,7 @@ Open an owner-reviewed draft with `buzz agents draft-create --channel `. Repeat `--mention` for multiple recipients. Any explicit identity (`--mention` or `nostr:npub...`) permits unresolved or ambiguous `@Name` text as presentation-only; uniquely resolved member names still add their own recipients. Include a pubkey for every presentation-only name that should notify. The success JSON's `mention_pubkeys` comes from the signed event and is the delivery evidence; no follow-up verification command is needed. - Without `--mention`, the CLI resolves `@Name` against current channel members. It stops before sending on an unresolved/ambiguous name or a mentioned pubkey that is not a member. For a non-member, add them explicitly with `buzz channels add-member` only when authorized, then retry. Sending never changes membership automatically. From 04babf02655440b4dfd37f2e2df605ead0a030d8 Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Wed, 2 Sep 2026 13:56:41 -0400 Subject: [PATCH 03/13] chore(db): drop Phase-A NIP-FI relay-side authority ledger (#7221) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removes the dead relay-side authority ledger introduced by migrations 0041 and 0042, and the dead `require_attested_key` verifier knob from `buzz-auth`. Both are unreachable by design under NIP-FI spec v2 (#7214, squash `d4420eb47`), which makes OSS Buzz stateless for identity: the relay neither stores nor verifies an authority chain. ## What changes **`migrations/0044_drop_nip_fi_ledger.sql`** Drops all fifteen NIP-FI ledger tables and their trigger functions using `CASCADE` to resolve the circular deferred FK between `identity_bindings` and `identity_lifecycle_history`. Drops proceed in FK dependency order: selectors → history/bindings → enrollment_policies/receipts → parallel drop of auth tables. Restores `community_write_fence_excluded_table` to its pre-0041 body (removes NIP-FI table names from the exclusion array). **`schema/schema.sql`** Removes the NIP-FI section (~1885 lines of tables, functions, and triggers) and updates `community_write_fence_excluded_table` to match. **`crates/buzz-db/src/runtime/migration.rs`** - Updates the `embedded_migrator_contains_consolidated_initial_schema` sanity check: count 43→44, adds 0044 assertion block (verifies `DROP TABLE` statements and absence of NIP-FI names from `schema.sql`). - Removes ~2580 lines of NIP-FI Postgres integration tests (all `#[tokio::test] #[ignore = "requires Postgres"]` from the 0041/0042 behavioral coverage). - Removes the `extract_excluded_table_array` drift check (0042 body no longer matches `schema.sql` by design). - Adds `migration_0044_drops_populated_nip_fi_ledger_cleanly`: runs migrations to 0042, seeds rows in `authorization_operation_receipts` and `authorization_invalidation_domains`, then runs to 0044 and verifies all fifteen NIP-FI tables are absent. **`crates/buzz-auth/src/nip_fi/config.rs`** Removes `require_attested_key: bool` from `IssuerPolicy` — field, constructor parameter, accessor, and its contribution to `derive_assertion_policy_id`. **`crates/buzz-auth/src/nip_fi/verifier.rs`** `parse_nostr_pubkey_claim` no longer takes a `policy` parameter. The `None` (absent claim) arm now returns `Err(VerifierError::ClaimRejected)` unconditionally instead of conditionally on `policy.require_attested_key()`. **`crates/buzz-auth/src/nip_fi/verifier/tests.rs`** - Removes `missing_nostr_pubkey_denies_under_attested_key_policy` (the sole `require_attested_key: true` call site). - Removes `false,` from all eleven `IssuerPolicy::new` call sites. - Injects `nostr_pubkey` by default in `mint_signed_by` (spec v2 requires it unconditionally). - Updates `valid_access_token_verifies` to assert `asserted_key().is_some()`. **`crates/buzz-auth/src/nip_fi/startup/tests.rs` + `jwks/tests.rs`** Removes `false,` from all `IssuerPolicy::new` call sites and adds `nostr_pubkey` to all token-minting helpers. ## Verification - Fresh-DB migration run to head: all migrations apply cleanly in sequence. - Populated-0041/0042-DB migration through 0044: seeds rows in live NIP-FI tables, verifies all fifteen are dropped without error. Closes the dead-code inventory item from the spec-v2 cleanup plan (channel `48374f48`). Follows #7214. --------- Signed-off-by: Will Pfleger Signed-off-by: Duncan Co-authored-by: Duncan --- crates/buzz-auth/src/nip_fi/config.rs | 17 +- crates/buzz-auth/src/nip_fi/jwks/tests.rs | 10 +- crates/buzz-auth/src/nip_fi/startup/tests.rs | 2 - crates/buzz-auth/src/nip_fi/verifier.rs | 15 +- crates/buzz-auth/src/nip_fi/verifier/tests.rs | 69 +- crates/buzz-db/src/runtime/migration.rs | 2640 +---------------- docs/nips/NIP-FI.md | 10 +- migrations/0044_drop_nip_fi_ledger.sql | 69 + schema/schema.sql | 1896 +----------- 9 files changed, 205 insertions(+), 4523 deletions(-) create mode 100644 migrations/0044_drop_nip_fi_ledger.sql diff --git a/crates/buzz-auth/src/nip_fi/config.rs b/crates/buzz-auth/src/nip_fi/config.rs index 37628669a1..8dabb00b12 100644 --- a/crates/buzz-auth/src/nip_fi/config.rs +++ b/crates/buzz-auth/src/nip_fi/config.rs @@ -69,7 +69,11 @@ pub(crate) const MAX_JWKS_KEYS: usize = 64; /// semantics** so prepared evidence built against an older contract is /// invalidated. Per-policy fields (issuer, class, bounds, …) are hashed /// separately and need no bump. -pub(crate) const VERIFIER_CONTRACT_VERSION: u32 = 1; +/// +/// v2 (PR #7221): `nostr_pubkey` absence now unconditionally rejects — the +/// per-issuer `require_attested_key` knob is removed and the NIP-FI v2 spec +/// requirement is always enforced. +pub(crate) const VERIFIER_CONTRACT_VERSION: u32 = 2; /// The transport-contract fingerprint folded into [`TransportContractId`]. /// **Bump on any change** to the client-attached parsing, attachment, @@ -347,7 +351,6 @@ pub struct IssuerPolicy { token_class: TokenClass, freshness: FreshnessClass, algorithms: Vec, - require_attested_key: bool, skew_seconds: u64, maximum_assertion_age_seconds: u64, maximum_status_age_seconds: Option, @@ -404,7 +407,6 @@ impl IssuerPolicy { token_class: TokenClass, freshness: FreshnessClass, algorithms: Vec, - require_attested_key: bool, skew_seconds: u64, maximum_assertion_age_seconds: u64, maximum_status_age_seconds: Option, @@ -467,7 +469,6 @@ impl IssuerPolicy { &token_class, freshness, &algorithms, - require_attested_key, skew_seconds, maximum_assertion_age_seconds, maximum_status_age_seconds, @@ -480,7 +481,6 @@ impl IssuerPolicy { token_class, freshness, algorithms, - require_attested_key, skew_seconds, maximum_assertion_age_seconds, maximum_status_age_seconds, @@ -514,11 +514,6 @@ impl IssuerPolicy { &self.algorithms } - /// Whether enrollment requires a `nostr_pubkey` claim equal to the actor. - pub const fn require_attested_key(&self) -> bool { - self.require_attested_key - } - /// The accepted clock skew, in seconds. pub const fn skew_seconds(&self) -> u64 { self.skew_seconds @@ -646,7 +641,6 @@ fn derive_assertion_policy_id( token_class: &TokenClass, freshness: FreshnessClass, algorithms: &[Algorithm], - require_attested_key: bool, skew_seconds: u64, maximum_assertion_age_seconds: u64, maximum_status_age_seconds: Option, @@ -702,7 +696,6 @@ fn derive_assertion_policy_id( &mut hasher, algorithms.iter().map(|a| algorithm_tag(*a).as_bytes()), ); - hasher.update([u8::from(require_attested_key)]); hasher.update(skew_seconds.to_be_bytes()); hasher.update(maximum_assertion_age_seconds.to_be_bytes()); hasher.update(maximum_status_age_seconds.unwrap_or(0).to_be_bytes()); diff --git a/crates/buzz-auth/src/nip_fi/jwks/tests.rs b/crates/buzz-auth/src/nip_fi/jwks/tests.rs index 6d9f21a150..df75e70da0 100644 --- a/crates/buzz-auth/src/nip_fi/jwks/tests.rs +++ b/crates/buzz-auth/src/nip_fi/jwks/tests.rs @@ -896,7 +896,9 @@ async fn two_issuer_keys_and_generations_are_isolated() { fn sign(pkcs8_pem: &str, kid: &str, iss: &str, aud: &str) -> String { let now = chrono::Utc::now().timestamp(); + // nostr_pubkey is required unconditionally by spec v2. let claims = json!({"iss": iss, "aud": aud, "sub": "u", + "nostr_pubkey": "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", "iat": now, "exp": now + 600}); let mut hdr = Header::new(Algorithm::ES256); hdr.kid = Some(kid.to_owned()); @@ -921,7 +923,6 @@ async fn two_issuer_keys_and_generations_are_isolated() { TokenClass::DedicatedNipFi, FreshnessClass::OfflineJwt, vec![Algorithm::ES256], - false, 60, 3600, None, @@ -1112,7 +1113,9 @@ async fn shared_arc_source_verifier_observes_rotation() { fn sign_token(pkcs8_pem: &str, kid: &str, iss: &str, aud: &str) -> String { let now = chrono::Utc::now().timestamp(); + // nostr_pubkey is required unconditionally by spec v2. let claims = json!({"iss": iss, "aud": aud, "sub": "u", + "nostr_pubkey": "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", "iat": now, "exp": now + 600}); let mut hdr = Header::new(Algorithm::ES256); hdr.kid = Some(kid.to_owned()); @@ -1171,7 +1174,6 @@ async fn shared_arc_source_verifier_observes_rotation() { TokenClass::DedicatedNipFi, FreshnessClass::OfflineJwt, vec![Algorithm::ES256], - false, 60, 3600, None, @@ -1233,7 +1235,6 @@ fn jwks_contract_uri_canonicalization_convergence_and_divergence() { TokenClass::DedicatedNipFi, FreshnessClass::OfflineJwt, vec![Algorithm::ES256], - false, 30, 600, None, @@ -1489,7 +1490,9 @@ async fn shared_arc_source_verifier_rejects_expired_a1_accepts_a2() { fn sign_token(pkcs8_pem: &str, kid: &str, iss: &str, aud: &str) -> String { let wall_now = chrono::Utc::now().timestamp(); + // nostr_pubkey is required unconditionally by spec v2. let claims = json!({"iss": iss, "aud": aud, "sub": "u", + "nostr_pubkey": "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", "iat": wall_now, "exp": wall_now + 600}); let mut hdr = Header::new(Algorithm::ES256); hdr.kid = Some(kid.to_owned()); @@ -1572,7 +1575,6 @@ async fn shared_arc_source_verifier_rejects_expired_a1_accepts_a2() { TokenClass::DedicatedNipFi, FreshnessClass::OfflineJwt, vec![Algorithm::ES256], - false, 60, HARD_DEADLINE_SECS, None, diff --git a/crates/buzz-auth/src/nip_fi/startup/tests.rs b/crates/buzz-auth/src/nip_fi/startup/tests.rs index 04b28a7b96..9b1d59b187 100644 --- a/crates/buzz-auth/src/nip_fi/startup/tests.rs +++ b/crates/buzz-auth/src/nip_fi/startup/tests.rs @@ -21,7 +21,6 @@ fn make_offline_policy(issuer: &str) -> IssuerPolicy { TokenClass::DedicatedNipFi, FreshnessClass::OfflineJwt, vec![JwtAlgorithm::ES256], - false, 0, 3600, None, @@ -37,7 +36,6 @@ fn make_status_policy(issuer: &str) -> IssuerPolicy { TokenClass::DedicatedNipFi, FreshnessClass::CurrentStatus, vec![JwtAlgorithm::ES256], - false, 0, 3600, Some(60), diff --git a/crates/buzz-auth/src/nip_fi/verifier.rs b/crates/buzz-auth/src/nip_fi/verifier.rs index aa3b5796a0..cf20b57a86 100644 --- a/crates/buzz-auth/src/nip_fi/verifier.rs +++ b/crates/buzz-auth/src/nip_fi/verifier.rs @@ -362,7 +362,7 @@ impl FederatedAssertionVerifier { enforce_claim_semantics(policy, &claims)?; let subject = claim_string(&claims, SUBJECT_CLAIM, MAX_SUBJECT_BYTES)?; - let asserted_key = parse_nostr_pubkey_claim(policy, &claims)?; + let asserted_key = parse_nostr_pubkey_claim(&claims)?; let now = Utc::now(); let deadlines = self.check_time_and_deadlines(policy, &key_set, &claims, now)?; @@ -720,20 +720,13 @@ fn enforce_claim_semantics( } /// Parse the fixed `nostr_pubkey` claim: lowercase hex of exactly one 32-byte -/// key. Bech32 and other aliases deny. Absence is permitted unless the policy -/// requires an attested key. +/// key. Bech32 and other aliases deny. Absence denies; the merged NIP-FI +/// spec v2 (PR #7214) requires the `nostr_pubkey` claim unconditionally. fn parse_nostr_pubkey_claim( - policy: &IssuerPolicy, claims: &Map, ) -> Result, VerifierError> { match claims.get(NOSTR_PUBKEY_CLAIM) { - None => { - if policy.require_attested_key() { - Err(VerifierError::ClaimRejected) - } else { - Ok(None) - } - } + None => Err(VerifierError::ClaimRejected), Some(value) => { let raw = value.as_str().ok_or(VerifierError::ClaimRejected)?; if raw.len() != 64 diff --git a/crates/buzz-auth/src/nip_fi/verifier/tests.rs b/crates/buzz-auth/src/nip_fi/verifier/tests.rs index 990a3310e4..8f6c60c40a 100644 --- a/crates/buzz-auth/src/nip_fi/verifier/tests.rs +++ b/crates/buzz-auth/src/nip_fi/verifier/tests.rs @@ -27,6 +27,9 @@ const TEST_JWK_Y: &str = "WqQXVwaD6NM7us40BUTNe9dRa1XoJ0NX6vJuJWYU_bA"; const TEST_KID: &str = "test-key-1"; const ISSUER: &str = "https://issuer.example"; const AUDIENCE: &str = "https://relay.example"; +/// A canonical lowercase-hex nostr pubkey for tokens that are not testing +/// the nostr_pubkey claim specifically. Spec v2 requires the claim unconditionally. +const TEST_NOSTR_PUBKEY: &str = "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef"; /// A canonical JWKS contract for the default test issuer. Used wherever a /// `JwksSourceContract` is required but JWKS behavior is not under test. @@ -109,7 +112,6 @@ fn access_token_policy_with(subject_class: SubjectClassContract) -> IssuerPolicy TokenClass::AccessTokenAtJwt { subject_class }, FreshnessClass::OfflineJwt, vec![Algorithm::ES256], - false, 60, 3600, None, @@ -131,7 +133,6 @@ fn dedicated_policy(issuer: &str) -> IssuerPolicy { TokenClass::DedicatedNipFi, FreshnessClass::OfflineJwt, vec![Algorithm::ES256], - false, 60, 3600, None, @@ -147,7 +148,6 @@ fn dedicated_policy_with_audiences(audiences: Vec) -> IssuerPolicy { TokenClass::DedicatedNipFi, FreshnessClass::OfflineJwt, vec![Algorithm::ES256], - false, 60, 3600, None, @@ -163,7 +163,6 @@ fn dedicated_policy_with_algorithms(algorithms: Vec) -> IssuerPolicy TokenClass::DedicatedNipFi, FreshnessClass::OfflineJwt, algorithms, - false, 60, 3600, None, @@ -198,6 +197,10 @@ fn mint_signed_by(pkcs8_pem: &str, typ: Option<&str>, kid: &str, mut claims: Val obj.entry("aud").or_insert(json!(AUDIENCE)); obj.entry("iat").or_insert(json!(now())); obj.entry("exp").or_insert(json!(now() + 600)); + // Spec v2 requires nostr_pubkey unconditionally; inject a canonical + // test pubkey so tokens that test other behaviours pass the claim check. + obj.entry(NOSTR_PUBKEY_CLAIM) + .or_insert(json!(TEST_NOSTR_PUBKEY)); } let mut header = Header::new(Algorithm::ES256); header.kid = Some(kid.to_owned()); @@ -206,6 +209,26 @@ fn mint_signed_by(pkcs8_pem: &str, typ: Option<&str>, kid: &str, mut claims: Val jsonwebtoken::encode(&header, &claims, &key).expect("sign") } +/// Mint a valid, signed token that deliberately omits `nostr_pubkey`. Used +/// only to exercise the unconditional missing-claim rejection path; the normal +/// `mint`/`mint_signed_by` helpers always inject the claim via `or_insert` so +/// they cannot produce an absent-claim token. +fn mint_no_pubkey(typ: Option<&str>, kid: &str, mut claims: Value) -> String { + { + let obj = claims.as_object_mut().expect("claims object"); + obj.entry("iss").or_insert(json!(ISSUER)); + obj.entry("aud").or_insert(json!(AUDIENCE)); + obj.entry("iat").or_insert(json!(now())); + obj.entry("exp").or_insert(json!(now() + 600)); + // Intentionally does NOT inject nostr_pubkey. + } + let mut header = Header::new(Algorithm::ES256); + header.kid = Some(kid.to_owned()); + header.typ = typ.map(str::to_owned); + let key = EncodingKey::from_ec_pem(TEST_EC_PKCS8_PEM.as_bytes()).expect("valid EC PEM"); + jsonwebtoken::encode(&header, &claims, &key).expect("sign") +} + /// A resource-owner `at+jwt` claim set: valid subject-class marker plus client_id. fn resource_owner_claims() -> Value { json!({ "sub": "user-123", "client_id": "app-1", "sub_type": "user" }) @@ -240,7 +263,8 @@ fn valid_access_token_verifies() { let assertion = verifier.verify(&token).expect("verifies"); assert_eq!(assertion.identity().issuer(), ISSUER); assert_eq!(assertion.identity().subject(), "user-123"); - assert!(assertion.asserted_key().is_none()); + // Spec v2: nostr_pubkey is injected by mint() and unconditionally required. + assert!(assertion.asserted_key().is_some()); assert!(!assertion.authority_deadlines().is_empty()); assert_eq!(assertion.assertion_policy_id(), access_token_policy().id()); } @@ -698,22 +722,18 @@ fn uppercase_nostr_pubkey_denies() { } #[test] -fn missing_nostr_pubkey_denies_under_attested_key_policy() { - let policy = IssuerPolicy::new( - ISSUER.to_owned(), - vec![AUDIENCE.to_owned()], - TokenClass::DedicatedNipFi, - FreshnessClass::OfflineJwt, - vec![Algorithm::ES256], - true, // require attested key - 60, - 3600, - None, - test_jwks_contract(), - ) - .unwrap(); - let verifier = verifier_with(policy); - let token = mint(Some("nip-fi+jwt"), TEST_KID, json!({ "sub": "u" })); +fn absent_nostr_pubkey_claim_denies() { + // `nostr_pubkey` absence must unconditionally reject — NIP-FI v2 dropped + // the per-issuer `require_attested_key` knob that previously made it + // optional. This is a direct falsifiable regression test: removing the + // `None => Err(VerifierError::ClaimRejected)` arm from + // `parse_nostr_pubkey_claim` must turn this test red. + let verifier = verifier_with(access_token_policy()); + let token = mint_no_pubkey( + Some("at+jwt"), + TEST_KID, + json!({ "sub": "u", "client_id": "a", "sub_type": "user" }), + ); assert_eq!( verifier.verify(&token).unwrap_err(), VerifierError::ClaimRejected @@ -1105,7 +1125,6 @@ fn current_status_policy() -> IssuerPolicy { TokenClass::DedicatedNipFi, FreshnessClass::CurrentStatus, vec![Algorithm::ES256], - false, 60, 3600, Some(120), // maximum_status_age required for current-status @@ -1385,7 +1404,6 @@ fn assertion_policy_id_is_deterministic_and_semantic() { changed.token_class().clone(), FreshnessClass::OfflineJwt, vec![Algorithm::ES256], - false, 120, // different skew => different semantics 3600, None, @@ -1411,7 +1429,6 @@ fn offline_policy_rejects_inapplicable_maximum_status_age() { TokenClass::DedicatedNipFi, FreshnessClass::OfflineJwt, vec![Algorithm::ES256], - false, 60, 3600, Some(120), @@ -1430,7 +1447,6 @@ fn offline_policy_accepts_absent_maximum_status_age() { TokenClass::DedicatedNipFi, FreshnessClass::OfflineJwt, vec![Algorithm::ES256], - false, 60, 3600, None, @@ -1449,7 +1465,6 @@ fn current_status_policy_still_requires_positive_maximum_status_age() { TokenClass::DedicatedNipFi, FreshnessClass::CurrentStatus, vec![Algorithm::ES256], - false, 60, 3600, None, @@ -1463,7 +1478,6 @@ fn current_status_policy_still_requires_positive_maximum_status_age() { TokenClass::DedicatedNipFi, FreshnessClass::CurrentStatus, vec![Algorithm::ES256], - false, 60, 3600, Some(0), @@ -1579,7 +1593,6 @@ fn policy_with_contract(contract: crate::nip_fi::jwks::JwksSourceContract) -> Is TokenClass::DedicatedNipFi, FreshnessClass::OfflineJwt, vec![Algorithm::ES256], - false, 60, 3600, None, diff --git a/crates/buzz-db/src/runtime/migration.rs b/crates/buzz-db/src/runtime/migration.rs index 68071e0ed2..3db40eddb7 100644 --- a/crates/buzz-db/src/runtime/migration.rs +++ b/crates/buzz-db/src/runtime/migration.rs @@ -699,7 +699,7 @@ mod postgres_tests { let mut migrations: Vec<_> = MIGRATOR.iter().collect(); migrations.sort_by_key(|migration| migration.version); - assert_eq!(migrations.len(), 43); + assert_eq!(migrations.len(), 44); assert_eq!(migrations[0].version, 1); assert_eq!(&*migrations[0].description, "initial schema"); assert!(migrations[0] @@ -1242,44 +1242,15 @@ mod postgres_tests { "migration 39 must register relay_operator_audit in _operator_global_tables" ); - // NIP-FI core identity + base-lifecycle foundation (migration 0041) and - // final-admission foundation (0042). Both widen the single SQL source of - // truth `community_write_fence_excluded_table` so their durable, - // immutable ledger relations are never fence-attached, purged, or - // counted as tenant-scoped drift. schema.sql keeps one consolidated - // definition of that function whose body must match 0042's exactly. assert_eq!(migrations[40].version, 41); let identity_foundation = migrations[40].sql.as_str(); assert!(identity_foundation.contains("CREATE TABLE identity_bindings")); assert!(identity_foundation.contains("CREATE TABLE identity_lifecycle_history")); - assert!(identity_foundation - .contains("CREATE OR REPLACE FUNCTION community_write_fence_excluded_table")); - assert!(identity_foundation.contains("'identity_bindings'")); assert_eq!(migrations[41].version, 42); let authorization_foundation = migrations[41].sql.as_str(); assert!(authorization_foundation.contains("CREATE TABLE authorization_events")); assert!(authorization_foundation.contains("CREATE TABLE protected_object_authority")); - assert!(authorization_foundation.contains("CREATE TABLE authorization_admission_results")); - - // The consolidated desired-state exclusion function must byte-match - // migration 0042's CREATE OR REPLACE body, or a future schema - // consolidation would silently drop NIP-FI relations from the ledger. - fn extract_excluded_table_array(sql: &str) -> &str { - let anchor = "community_write_fence_excluded_table(target NAME) RETURNS BOOLEAN"; - let start = sql.find(anchor).expect("exclusion function definition"); - let array_start = sql[start..].find("ARRAY[").expect("exclusion array") + start; - let array_end = sql[array_start..] - .find("]::TEXT[]") - .expect("exclusion array end") - + array_start; - &sql[array_start..array_end] - } - assert_eq!( - extract_excluded_table_array(authorization_foundation), - extract_excluded_table_array(desired_schema), - "schema.sql exclusion list drifted from migration 0042" - ); // Brownfield relay databases created through SQLx still carry the // production/sandbox constraint from 0015. Converge them to the same @@ -1292,6 +1263,27 @@ mod postgres_tests { .contains("DROP CONSTRAINT push_gateway_installations_app_profile_check")); assert!(dogfood_profile.contains("CHECK (app_profile = 'buzz-ios-dogfood')")); assert!(desired_schema.contains("CHECK (app_profile = 'buzz-ios-dogfood')")); + + // Drop the Phase-A NIP-FI relay-side authority ledger (0041 + 0042). + // OSS Buzz is stateless for identity (spec v2, PR #7214); the durable + // ledger tables are dead code. Restores community_write_fence_excluded_table + // to its pre-0041 body so the deletion catalog no longer includes the + // removed relations. + assert_eq!(migrations[43].version, 44); + let ledger_removal = migrations[43].sql.as_str(); + assert!(ledger_removal.contains("DROP TABLE authorization_operation_receipts")); + assert!(ledger_removal.contains("DROP TABLE identity_bindings")); + assert!(ledger_removal.contains("DROP TABLE authorization_events")); + assert!(ledger_removal + .contains("CREATE OR REPLACE FUNCTION community_write_fence_excluded_table")); + // The restored exclusion function must NOT list any NIP-FI relation. + assert!(!ledger_removal.contains("'authorization_operation_receipts'")); + assert!(!ledger_removal.contains("'identity_bindings'")); + // schema.sql exclusion list must match the restored (pre-0041) body. + assert!( + desired_schema.contains("'rate_limit_violations'\n ]::TEXT[])"), + "schema.sql exclusion list must match the pre-0041 body after ledger removal" + ); } #[test] @@ -2670,104 +2662,13 @@ mod postgres_tests { .expect("drop late-table fixtures"); } - /// NIP-FI intermediate state: migration 0041 (identity + base lifecycle) - /// alone must present a coherent catalog. Its five community-scoped ledger - /// relations are immutable and durable, so they are registered in the - /// write-fence exclusion — never counted as tenant-scoped drift, never - /// fence-attached — and the exact deletion catalog must still validate. + /// Verify migration 0044 applies cleanly against a DB that has rows in + /// the NIP-FI 0041+0042 tables. The immutability guards (no_delete, + /// no_truncate) are enforced via triggers; DROP TABLE bypasses them and + /// must succeed even when rows are present. #[tokio::test] #[ignore = "requires Postgres"] - async fn migration_0041_identity_foundation_is_durable_ledger_after_migration_a() { - let pool = connect_test_pool().await; - reset_public_schema(&pool).await; - MIGRATOR - .run_to(41, &pool) - .await - .expect("apply migrations 1-41"); - - // The five identity relations exist. - let identity_tables = [ - "authorization_operation_receipts", - "identity_enrollment_policies", - "identity_bindings", - "identity_lifecycle_history", - "identity_lifecycle_selectors", - ]; - for table in identity_tables { - let exists = sqlx::query_scalar::<_, bool>( - "SELECT EXISTS (SELECT 1 FROM information_schema.tables \ - WHERE table_schema = 'public' AND table_name = $1)", - ) - .bind(table) - .fetch_one(&pool) - .await - .unwrap_or_else(|err| panic!("check table {table}: {err}")); - assert!(exists, "migration 0041 must create {table}"); - } - - // Migration B's relations must NOT exist yet. - for table in ["authorization_events", "protected_object_authority"] { - let exists = sqlx::query_scalar::<_, bool>( - "SELECT EXISTS (SELECT 1 FROM information_schema.tables \ - WHERE table_schema = 'public' AND table_name = $1)", - ) - .bind(table) - .fetch_one(&pool) - .await - .unwrap_or_else(|err| panic!("check table {table}: {err}")); - assert!(!exists, "{table} belongs to migration 0042, not 0041"); - } - - // Every identity relation is excluded from the write fence: none may - // appear as tenant-scoped drift or carry the fence trigger. - let scoped_or_fenced: Vec = sqlx::query_scalar( - "WITH scoped AS ( \ - SELECT c.relname FROM pg_class c \ - JOIN pg_namespace n ON n.oid = c.relnamespace \ - JOIN pg_attribute a ON a.attrelid = c.oid \ - WHERE n.nspname = 'public' AND c.relkind IN ('r','p') \ - AND NOT c.relispartition AND a.attname = 'community_id' \ - AND NOT a.attisdropped \ - AND NOT community_write_fence_excluded_table(c.relname) \ - ) \ - SELECT relname FROM scoped \ - WHERE relname = ANY($1) ORDER BY relname", - ) - .bind(&identity_tables[..]) - .fetch_all(&pool) - .await - .expect("read scoped identity relations"); - assert!( - scoped_or_fenced.is_empty(), - "identity ledger relations must be write-fence excluded, not scoped: {scoped_or_fenced:?}" - ); - - // The exact deletion catalog validates: the excluded ledger relations - // do not perturb the scoped-table/fence equality check. - crate::deletion::DeletionStore::new(pool.clone()) - .validate_catalog() - .await - .expect("deletion catalog validates after migration 0041"); - - // The immutability contract is enforced, not merely declared. TRUNCATE - // fires the statement-level guard unconditionally, so this proves the - // rejection without constructing a fully valid ledger row. - let rejected = sqlx::query("TRUNCATE identity_lifecycle_selectors") - .execute(&pool) - .await - .expect_err("identity_lifecycle_selectors truncation must be rejected"); - assert!( - rejected.to_string().contains("cannot be truncated"), - "expected immutability rejection, got: {rejected}" - ); - } - - /// NIP-FI full state: migrations 0041 + 0042 together must present a - /// coherent 15-relation catalog with zero dangling foreign keys, all - /// relations write-fence excluded, and an intact exact deletion catalog. - #[tokio::test] - #[ignore = "requires Postgres"] - async fn nip_fi_foundation_is_a_closed_durable_ledger_after_migrations_a_and_b() { + async fn migration_0044_drops_populated_nip_fi_ledger_cleanly() { let pool = connect_test_pool().await; reset_public_schema(&pool).await; MIGRATOR @@ -2775,6 +2676,49 @@ mod postgres_tests { .await .expect("apply migrations 1-42"); + // Seed a community and minimal rows in a selection of 0041+0042 tables. + let community_id = uuid::Uuid::new_v4(); + sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)") + .bind(community_id) + .bind(format!("drop-test-{}.example", community_id.simple())) + .execute(&pool) + .await + .expect("seed community"); + + // Seed a receipt (used as FK anchor for several 0042 tables). + let operation_id = uuid::Uuid::new_v4(); + sqlx::query( + "INSERT INTO authorization_operation_receipts \ + (community_id, operation_id, request_fingerprint, operation_kind, \ + actor_fingerprint, outcome_code, result_digest) \ + VALUES ($1, $2, $3, 12, $4, 1, $5)", + ) + .bind(community_id) + .bind(operation_id) + .bind(vec![0xAA_u8; 32]) + .bind(vec![0xBB_u8; 32]) + .bind(vec![0xCC_u8; 32]) + .execute(&pool) + .await + .expect("seed operation receipt"); + + // Seed an invalidation domain (0042 table with no FK to receipts). + sqlx::query( + "INSERT INTO authorization_invalidation_domains \ + (community_id, current_generation) VALUES ($1, 0)", + ) + .bind(community_id) + .execute(&pool) + .await + .expect("seed invalidation domain"); + + // Apply migration 0043 (dogfood profile) and 0044 (ledger removal). + MIGRATOR + .run_to(44, &pool) + .await + .expect("migration 0044 must apply cleanly against a populated NIP-FI DB"); + + // All NIP-FI tables must be gone. let nip_fi_tables = [ "authorization_admission_results", "authorization_authentication_denial_attempts", @@ -2792,2455 +2736,23 @@ mod postgres_tests { "identity_lifecycle_selectors", "protected_object_authority", ]; - - // All fifteen relations exist. let present: Vec = sqlx::query_scalar( "SELECT table_name FROM information_schema.tables \ - WHERE table_schema = 'public' AND table_name = ANY($1) ORDER BY table_name", + WHERE table_schema = 'public' AND table_name = ANY($1)", ) .bind(&nip_fi_tables[..]) .fetch_all(&pool) .await - .expect("read NIP-FI table catalog"); - let mut expected: Vec = nip_fi_tables.iter().map(|t| t.to_string()).collect(); - expected.sort(); - assert_eq!( - present, expected, - "all NIP-FI relations must exist after 0042" - ); - - // Zero dangling foreign keys: every FK target is a live relation. - let invalid_fks: i64 = sqlx::query_scalar( - "SELECT count(*)::BIGINT FROM pg_constraint \ - WHERE contype = 'f' AND NOT convalidated", - ) - .fetch_one(&pool) - .await - .expect("read FK validity"); - assert_eq!( - invalid_fks, 0, - "no NIP-FI foreign key may be left unvalidated" - ); - - // None of the fifteen appear as tenant-scoped drift; all are excluded. - let scoped: Vec = sqlx::query_scalar( - "SELECT c.relname FROM pg_class c \ - JOIN pg_namespace n ON n.oid = c.relnamespace \ - JOIN pg_attribute a ON a.attrelid = c.oid \ - WHERE n.nspname = 'public' AND c.relkind IN ('r','p') \ - AND NOT c.relispartition AND a.attname = 'community_id' \ - AND NOT a.attisdropped \ - AND NOT community_write_fence_excluded_table(c.relname) \ - AND c.relname = ANY($1) ORDER BY c.relname", - ) - .bind(&nip_fi_tables[..]) - .fetch_all(&pool) - .await - .expect("read scoped NIP-FI relations"); + .expect("catalog check after ledger removal"); assert!( - scoped.is_empty(), - "all NIP-FI ledger relations must be write-fence excluded: {scoped:?}" + present.is_empty(), + "all NIP-FI tables must be absent after migration 0044: {present:?}" ); - // The exact deletion catalog validates with the full ledger present. + // The deletion catalog must validate with ledger relations gone. crate::deletion::DeletionStore::new(pool.clone()) .validate_catalog() .await - .expect("deletion catalog validates after migrations 0041 + 0042"); - - // A migration-B relation is immutable too. TRUNCATE fires the - // statement-level guard unconditionally. - let rejected = sqlx::query("TRUNCATE authorization_admission_results") - .execute(&pool) - .await - .expect_err("authorization_admission_results truncation must be rejected"); - assert!( - rejected.to_string().contains("cannot be truncated"), - "expected immutability rejection, got: {rejected}" - ); - } - - /// NIP-FI monotonic invalidation-floor advancement must actually run - /// through the `BEFORE UPDATE` guard. PL/pgSQL defers record-field - /// resolution to execution, so a guard that references a column absent from - /// its Phase-A table passes every catalog/parity test yet aborts the first - /// real advancement. This test exercises live UPDATEs: legitimate forward - /// moves on `floor_generation` and `binding_version_floor` must commit, and - /// equal/regressive moves must be rejected. - #[tokio::test] - #[ignore = "requires Postgres"] - async fn authorization_invalidation_floor_advances_through_guard() { - let pool = connect_test_pool().await; - reset_public_schema(&pool).await; - MIGRATOR - .run_to(42, &pool) - .await - .expect("apply migrations 1-42"); - - let community_id = uuid::Uuid::new_v4(); - sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)") - .bind(community_id) - .bind(format!("floor-guard-{}.example", community_id.simple())) - .execute(&pool) - .await - .expect("insert community"); - - // Each floor state points at an operation receipt via - // (community_id, operation_id, request_fingerprint). Seed one receipt - // per operation the test advances through. - let operations: [(uuid::Uuid, u8); 4] = [ - (uuid::Uuid::new_v4(), 0x11), - (uuid::Uuid::new_v4(), 0x22), - (uuid::Uuid::new_v4(), 0x33), - (uuid::Uuid::new_v4(), 0x44), - ]; - for (operation_id, fp_byte) in operations { - sqlx::query( - "INSERT INTO authorization_operation_receipts \ - (community_id, operation_id, request_fingerprint, operation_kind, \ - actor_fingerprint, outcome_code, result_digest) \ - VALUES ($1, $2, $3, 12, $4, 1, $5)", - ) - .bind(community_id) - .bind(operation_id) - .bind(vec![fp_byte; 32]) - .bind(vec![0xAA_u8; 32]) - .bind(vec![0xBB_u8; 32]) - .execute(&pool) - .await - .expect("seed operation receipt"); - } - - // selector_kind 3 requires binding_version_floor, so this row exercises - // both monotonic dimensions the guard still governs. - let selector_fingerprint = vec![0xCC_u8; 32]; - sqlx::query( - "INSERT INTO authorization_invalidation_floors \ - (community_id, selector_kind, selector_fingerprint, floor_generation, \ - binding_version_floor, operation_id, request_fingerprint, updated_at) \ - VALUES ($1, 3, $2, 1, 1, $3, $4, '2026-01-01T00:00:00Z')", - ) - .bind(community_id) - .bind(&selector_fingerprint) - .bind(operations[0].0) - .bind(vec![operations[0].1; 32]) - .execute(&pool) - .await - .expect("insert initial invalidation floor"); - - let advance = - |generation: i64, binding_floor: i64, op_index: usize, updated_at: &'static str| { - sqlx::query( - "UPDATE authorization_invalidation_floors \ - SET floor_generation = $1, binding_version_floor = $2, \ - operation_id = $3, request_fingerprint = $4, updated_at = $5::timestamptz \ - WHERE community_id = $6 AND selector_kind = 3 AND selector_fingerprint = $7", - ) - .bind(generation) - .bind(binding_floor) - .bind(operations[op_index].0) - .bind(vec![operations[op_index].1; 32]) - .bind(updated_at) - .bind(community_id) - .bind(selector_fingerprint.clone()) - .execute(&pool) - }; - - // Forward generation advance commits. - advance(2, 1, 1, "2026-01-01T00:01:00Z") - .await - .expect("forward floor_generation advance must pass the guard"); - - // Forward binding_version_floor advance commits (generation unchanged). - advance(2, 2, 2, "2026-01-01T00:02:00Z") - .await - .expect("forward binding_version_floor advance must pass the guard"); - - // Regressive generation is rejected. - let regressive = advance(1, 2, 3, "2026-01-01T00:03:00Z") - .await - .expect_err("regressive floor_generation must be rejected"); - assert!( - regressive.to_string().contains("cannot move backward"), - "expected monotonic rejection, got: {regressive}" - ); - - // Equal floors with only a new operation is a rejected no-op advance. - let no_op = advance(2, 2, 3, "2026-01-01T00:03:00Z") - .await - .expect_err("equal-floor no-op advance must be rejected"); - assert!( - no_op.to_string().contains("cannot move backward"), - "expected no-op rejection, got: {no_op}" - ); - - // The committed state reflects only the two accepted advances. - let (generation, binding_floor): (i64, i64) = sqlx::query_as( - "SELECT floor_generation, binding_version_floor \ - FROM authorization_invalidation_floors \ - WHERE community_id = $1 AND selector_kind = 3 AND selector_fingerprint = $2", - ) - .bind(community_id) - .bind(&selector_fingerprint) - .fetch_one(&pool) - .await - .expect("read final floor state"); - assert_eq!( - (generation, binding_floor), - (2, 2), - "only the accepted forward advances may persist" - ); - } - - /// NIP-FI identity FK contract: a binding's provenance is determined from - /// operation evidence and is independent of the enrollment policy's mode. - /// The corrected FK references only `(community_id, policy_revision)`; - /// the original composite FK `(community_id, policy_revision, - /// binding_provenance) → (community_id, policy_revision, enrollment_mode)` - /// would have rejected valid admissions such as TOFU policy + - /// attested-key provenance (NIP-FI.md §352, §424). - #[tokio::test] - #[ignore = "requires Postgres"] - async fn identity_binding_provenance_is_independent_of_enrollment_mode() { - let pool = connect_test_pool().await; - reset_public_schema(&pool).await; - MIGRATOR - .run_to(41, &pool) - .await - .expect("apply migrations 1-41"); - - let community_id = uuid::Uuid::new_v4(); - sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)") - .bind(community_id) - .bind(format!("provenance-{}.example", community_id.simple())) - .execute(&pool) - .await - .expect("insert community"); - - // Enrollment policy: mode 3 (TOFU). - let policy_revision: i64 = 1; - sqlx::query( - "INSERT INTO identity_enrollment_policies \ - (community_id, policy_revision, enrollment_mode, policy_digest, effective_at) \ - VALUES ($1, $2, 3, $3, '2026-01-01T00:00:00Z')", - ) - .bind(community_id) - .bind(policy_revision) - .bind(vec![0xA0_u8; 32]) // policy_digest - .execute(&pool) - .await - .expect("insert TOFU enrollment policy"); - - // Insert a binding with provenance 1 (attested-key) under the TOFU - // policy. The circular deferred FK between identity_bindings and - // identity_lifecycle_history requires both to be committed in one - // transaction; all cross-table FKs in this pair are DEFERRABLE - // INITIALLY DEFERRED. A pinned connection is required so that BEGIN - // and each subsequent statement share the same session/transaction. - let binding_id = uuid::Uuid::new_v4(); - let history_id = uuid::Uuid::new_v4(); - let operation_id = uuid::Uuid::new_v4(); - let request_fingerprint = vec![0xAB_u8; 32]; - - let mut conn = pool.acquire().await.expect("acquire connection"); - - sqlx::query("BEGIN") - .execute(&mut *conn) - .await - .expect("begin"); - - // Enrollment history must be inserted BEFORE the operation receipt: - // authorization_operation_receipt_history_guard_v1 fires AFTER INSERT - // on authorization_operation_receipts and checks that lifecycle receipts - // already have exactly one history row. The history → receipt FK is - // DEFERRABLE INITIALLY DEFERRED, so this order is safe. - sqlx::query( - "INSERT INTO identity_lifecycle_history \ - (community_id, history_id, transition_kind, outcome_code, \ - successor_binding_id, successor_binding_version, \ - successor_lifecycle_revision, successor_state, \ - operation_id, request_fingerprint, transition_digest) \ - VALUES ($1, $2, 1, 1, $3, 1, 1, 1, $4, $5, $6)", - ) - .bind(community_id) - .bind(history_id) - .bind(binding_id) - .bind(operation_id) - .bind(&request_fingerprint) - .bind(vec![0xAE_u8; 32]) - .execute(&mut *conn) - .await - .expect("insert lifecycle history"); - - // Operation receipt: kind 1 (enroll), outcome 1 (applied). - // The receipt_history_cardinality trigger fires here and validates the - // history row inserted above. - sqlx::query( - "INSERT INTO authorization_operation_receipts \ - (community_id, operation_id, request_fingerprint, operation_kind, \ - actor_fingerprint, outcome_code, result_digest) \ - VALUES ($1, $2, $3, 1, $4, 1, $5)", - ) - .bind(community_id) - .bind(operation_id) - .bind(&request_fingerprint) - .bind(vec![0xAC_u8; 32]) - .bind(vec![0xAD_u8; 32]) - .execute(&mut *conn) - .await - .expect("insert operation receipt"); - - // Binding: provenance 1 (attested-key) under TOFU-mode policy. - // Before the FK fix this INSERT would fail at commit with a FK - // violation because 1 (attested-key) ≠ 3 (TOFU mode). - sqlx::query( - "INSERT INTO identity_bindings \ - (community_id, binding_id, issuer, subject, \ - principal_fingerprint, event_author_pubkey, \ - binding_state, lifecycle_revision, binding_provenance, \ - policy_revision, enrollment_evidence_digest, \ - birth_history_id, creation_operation_id, \ - creation_request_fingerprint) \ - VALUES ($1, $2, 'https://issuer.example', 'sub-01', \ - $3, $4, 1, 1, 1, $5, $6, $7, $8, $9)", - ) - .bind(community_id) - .bind(binding_id) - .bind(vec![0xAF_u8; 32]) // principal_fingerprint - .bind(vec![0xB0_u8; 32]) // event_author_pubkey - .bind(policy_revision) - .bind(vec![0xB1_u8; 32]) // enrollment_evidence_digest - .bind(history_id) - .bind(operation_id) - .bind(&request_fingerprint) - .execute(&mut *conn) - .await - .expect("insert binding"); - - sqlx::query("COMMIT") - .execute(&mut *conn) - .await - .expect("attested-key binding under TOFU policy must commit — FK is on (community_id, policy_revision) only"); - - // Confirm the binding persisted with provenance 1, policy mode 3. - let (stored_provenance, stored_mode): (i16, i16) = sqlx::query_as( - "SELECT b.binding_provenance, p.enrollment_mode \ - FROM identity_bindings b \ - JOIN identity_enrollment_policies p \ - ON p.community_id = b.community_id AND p.policy_revision = b.policy_revision \ - WHERE b.community_id = $1 AND b.binding_id = $2", - ) - .bind(community_id) - .bind(binding_id) - .fetch_one(&pool) - .await - .expect("read persisted binding"); - assert_eq!(stored_provenance, 1, "provenance must be attested-key (1)"); - assert_eq!(stored_mode, 3, "enrollment mode must be TOFU (3)"); - assert_ne!( - stored_provenance, stored_mode, - "provenance and mode are independent: they must differ here" - ); - - // --- Negative half: absent policy revision --- - // - // Two-sided mutation sensitivity requires that a FK dropped or neutered - // entirely is also detected. A second otherwise-valid deferred - // transaction uses a nonexistent policy_revision (999) and must fail - // with SQLSTATE 23503 — the narrowed FK - // identity_bindings(community_id, policy_revision) - // → identity_enrollment_policies(community_id, policy_revision) - // rejects the row. This FK is not deferred, so it fires at INSERT - // time; a `COMMIT` is unnecessary and not reached. If the FK were - // absent the INSERT would succeed and this assertion would catch the - // regression. - let absent_binding_id = uuid::Uuid::new_v4(); - let absent_history_id = uuid::Uuid::new_v4(); - let absent_operation_id = uuid::Uuid::new_v4(); - let absent_fp = vec![0xC0_u8; 32]; - let nonexistent_policy_revision: i64 = 999; - - let mut conn2 = pool.acquire().await.expect("acquire second connection"); - - sqlx::query("BEGIN") - .execute(&mut *conn2) - .await - .expect("begin absent-policy transaction"); - - // History first (receipt_history_cardinality guard fires on receipt - // insert and requires the history row to already exist). - sqlx::query( - "INSERT INTO identity_lifecycle_history \ - (community_id, history_id, transition_kind, outcome_code, \ - successor_binding_id, successor_binding_version, \ - successor_lifecycle_revision, successor_state, \ - operation_id, request_fingerprint, transition_digest) \ - VALUES ($1, $2, 1, 1, $3, 2, 1, 1, $4, $5, $6)", - ) - .bind(community_id) - .bind(absent_history_id) - .bind(absent_binding_id) - .bind(absent_operation_id) - .bind(&absent_fp) - .bind(vec![0xC1_u8; 32]) - .execute(&mut *conn2) - .await - .expect("insert absent-policy lifecycle history"); - - sqlx::query( - "INSERT INTO authorization_operation_receipts \ - (community_id, operation_id, request_fingerprint, operation_kind, \ - actor_fingerprint, outcome_code, result_digest) \ - VALUES ($1, $2, $3, 1, $4, 1, $5)", - ) - .bind(community_id) - .bind(absent_operation_id) - .bind(&absent_fp) - .bind(vec![0xC2_u8; 32]) - .bind(vec![0xC3_u8; 32]) - .execute(&mut *conn2) - .await - .expect("insert absent-policy operation receipt"); - - // The policy FK is not deferred; it fires at INSERT, not COMMIT. - let absent_policy_err = sqlx::query( - "INSERT INTO identity_bindings \ - (community_id, binding_id, issuer, subject, \ - principal_fingerprint, event_author_pubkey, \ - binding_state, lifecycle_revision, binding_provenance, \ - policy_revision, enrollment_evidence_digest, \ - birth_history_id, creation_operation_id, \ - creation_request_fingerprint) \ - VALUES ($1, $2, 'https://issuer.example', 'sub-02', \ - $3, $4, 1, 1, 1, $5, $6, $7, $8, $9)", - ) - .bind(community_id) - .bind(absent_binding_id) - .bind(vec![0xC4_u8; 32]) // principal_fingerprint (unique, different from first binding) - .bind(vec![0xC5_u8; 32]) // event_author_pubkey (unique, different from first binding) - .bind(nonexistent_policy_revision) - .bind(vec![0xC6_u8; 32]) // enrollment_evidence_digest - .bind(absent_history_id) - .bind(absent_operation_id) - .bind(&absent_fp) - .execute(&mut *conn2) - .await - .expect_err("binding with nonexistent policy_revision must be rejected by the FK"); - assert!( - absent_policy_err - .as_database_error() - .map(|e| e.code().as_deref() == Some("23503")) - .unwrap_or(false), - "expected FK violation (23503) for absent policy_revision, got: {absent_policy_err}" - ); - - sqlx::query("ROLLBACK") - .execute(&mut *conn2) - .await - .expect("rollback absent-policy transaction"); - } - - /// NIP-FI policy-revision monotonicity: each new policy revision for a - /// community must strictly exceed the current maximum revision - /// (FI-INV-06 — stable assertion policy). `effective_at` ordering is - /// deliberately not enforced — the downstream constructor stamps every - /// immediately-effective revision with Unix epoch. - /// - /// Mutation sensitivity is two-sided: - /// - neutering the guard lets a replayed or backfilled revision through - /// (the positive half detects insertion into a guarded table); - /// - leaving the guard intact rejects equal/regressive inserts (negative - /// halves detect that each rejection fires). - #[tokio::test] - #[ignore = "requires Postgres"] - async fn identity_enrollment_policy_revision_is_monotonic() { - let pool = connect_test_pool().await; - reset_public_schema(&pool).await; - MIGRATOR - .run_to(41, &pool) - .await - .expect("apply migrations 1-41"); - - let community_id = uuid::Uuid::new_v4(); - sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)") - .bind(community_id) - .bind(format!("policy-mono-{}.example", community_id.simple())) - .execute(&pool) - .await - .expect("insert community"); - - // First insertion: no prior rows — should always succeed. - sqlx::query( - "INSERT INTO identity_enrollment_policies \ - (community_id, policy_revision, enrollment_mode, policy_digest, effective_at) \ - VALUES ($1, 1, 1, $2, '2026-01-01T00:00:00Z')", - ) - .bind(community_id) - .bind(vec![0xA1_u8; 32]) - .execute(&pool) - .await - .expect("first policy insertion (revision 1) must succeed"); - - // Forward advance: revision 2. - sqlx::query( - "INSERT INTO identity_enrollment_policies \ - (community_id, policy_revision, enrollment_mode, policy_digest, effective_at) \ - VALUES ($1, 2, 1, $2, '2026-06-01T00:00:00Z')", - ) - .bind(community_id) - .bind(vec![0xA2_u8; 32]) - .execute(&pool) - .await - .expect("forward advance to revision 2 must succeed"); - - // Seed a gap: skip from 2 to 100, then advance to 101. - sqlx::query( - "INSERT INTO identity_enrollment_policies \ - (community_id, policy_revision, enrollment_mode, policy_digest, effective_at) \ - VALUES ($1, 100, 1, $2, '2027-01-01T00:00:00Z')", - ) - .bind(community_id) - .bind(vec![0xA3_u8; 32]) - .execute(&pool) - .await - .expect("jump to revision 100 must succeed"); - - sqlx::query( - "INSERT INTO identity_enrollment_policies \ - (community_id, policy_revision, enrollment_mode, policy_digest, effective_at) \ - VALUES ($1, 101, 1, $2, '2027-06-01T00:00:00Z')", - ) - .bind(community_id) - .bind(vec![0xA4_u8; 32]) - .execute(&pool) - .await - .expect("advance to revision 101 must succeed"); - - // Negative: unused lower revision 99 — not a PK duplicate (never inserted), - // but the guard must reject it because 99 < MAX(100, 101). This is the - // case a plain PK constraint cannot catch; the named guard must fire. - let backfill_err = sqlx::query( - "INSERT INTO identity_enrollment_policies \ - (community_id, policy_revision, enrollment_mode, policy_digest, effective_at) \ - VALUES ($1, 99, 1, $2, '2028-01-01T00:00:00Z')", - ) - .bind(community_id) - .bind(vec![0xA5_u8; 32]) - .execute(&pool) - .await - .expect_err("unused lower revision 99 must be rejected by the guard"); - assert!( - backfill_err - .as_database_error() - .map(|e| e.code().as_deref() == Some("23514")) - .unwrap_or(false), - "expected check_violation (23514) from identity_enrollment_policy_revision_monotonic \ - guard for backfilled revision 99, got: {backfill_err}" - ); - - // Negative: equal revision (101 <= 101). The PK is (community_id, policy_revision) - // so this is a PK duplicate regardless of policy_digest; either 23505 from the PK - // or 23514 from the guard fires first. This case is secondary — the load-bearing - // proof is the unused-99 case above, which is not a PK duplicate. - let replay_err = sqlx::query( - "INSERT INTO identity_enrollment_policies \ - (community_id, policy_revision, enrollment_mode, policy_digest, effective_at) \ - VALUES ($1, 101, 2, $2, '2028-01-01T00:00:00Z')", - ) - .bind(community_id) - .bind(vec![0xA6_u8; 32]) - .execute(&pool) - .await - .expect_err("equal revision must be rejected"); - // PK (23505) or guard (23514) — either proves the insert cannot commit. - assert!( - replay_err - .as_database_error() - .map(|e| { - let code = e.code(); - let c = code.as_deref().unwrap_or(""); - c == "23514" || c == "23505" - }) - .unwrap_or(false), - "expected check_violation (23514) or unique_violation (23505) for replayed revision, \ - got: {replay_err}" - ); - - // Concurrency regression: prove the advisory lock is load-bearing. The - // test uses a controlled two-connection schedule: - // - // 1. tx1 opens a transaction and inserts revision 102. The BEFORE INSERT - // trigger acquires `pg_advisory_xact_lock(lock_key)` and completes the - // INSERT — tx1 now holds the advisory lock until it commits. - // 2. tx2 opens a transaction on a second backend and issues INSERT for - // revision 103. The trigger fires and blocks inside - // `pg_advisory_xact_lock(lock_key)` waiting for tx1 to release. - // 3. We observe tx2's backend entering a Lock-wait state via - // pg_stat_activity (wait_event_type='Lock', wait_event='advisory'), - // with a bounded timeout — not a sleep. If the advisory-lock call is - // removed from the guard, the trigger returns immediately; tx2 never - // enters the advisory wait, and the poll times out, failing the test. - // This is the mutation-sensitivity guarantee. - // 4. tx1 commits, releasing the advisory lock. tx2 unblocks, its trigger - // reads the fresh MAX=102, and the INSERT succeeds (103 > 102). - // 5. tx2 commits. Both revisions 102 and 103 are present. - use std::time::Instant; - - // tx1: open a transaction and insert revision 102. The INSERT returns after - // the trigger acquires the lock and succeeds; the advisory lock stays held - // until the transaction commits. - let mut conn1 = pool.acquire().await.expect("acquire conn1"); - sqlx::query("BEGIN") - .execute(&mut *conn1) - .await - .expect("begin tx1"); - sqlx::query( - "INSERT INTO identity_enrollment_policies \ - (community_id, policy_revision, enrollment_mode, policy_digest, effective_at) \ - VALUES ($1, 102, 1, $2, '2029-01-01T00:00:00Z')", - ) - .bind(community_id) - .bind(vec![0xB1_u8; 32]) - .execute(&mut *conn1) - .await - .expect("tx1 INSERT revision 102 must succeed"); - // tx1 holds the advisory lock. Do NOT commit yet. - - // tx2: acquire a separate backend, record its PID, then issue the INSERT. - // The trigger will block on the advisory lock held by tx1. - let pool2 = pool.clone(); - let pool3 = pool.clone(); - let (pid_tx, pid_rx) = tokio::sync::oneshot::channel::(); - let tx2_task = tokio::spawn(async move { - let mut conn2 = pool2.acquire().await.expect("acquire conn2"); - // Report this backend's PID so the observer can poll pg_stat_activity. - let backend_pid: i32 = sqlx::query_scalar("SELECT pg_backend_pid()") - .fetch_one(&mut *conn2) - .await - .expect("get conn2 backend pid"); - let _ = pid_tx.send(backend_pid); - sqlx::query("BEGIN") - .execute(&mut *conn2) - .await - .expect("begin tx2"); - // This INSERT will block inside the trigger waiting for tx1's advisory lock. - let insert_r = sqlx::query( - "INSERT INTO identity_enrollment_policies \ - (community_id, policy_revision, enrollment_mode, policy_digest, effective_at) \ - VALUES ($1, 103, 1, $2, '2029-06-01T00:00:00Z')", - ) - .bind(community_id) - .bind(vec![0xB2_u8; 32]) - .execute(&mut *conn2) - .await; - let commit_r = sqlx::query("COMMIT").execute(&mut *conn2).await; - (insert_r, commit_r) - }); - - // Receive tx2's backend PID and wait until it enters an advisory-lock wait. - // Mutation proof: without pg_advisory_xact_lock in the guard, the trigger - // returns immediately; tx2 never parks on an advisory lock; the poll below - // times out and panics, making this test deterministically red. - let tx2_pid = pid_rx.await.expect("tx2 reports its backend pid"); - let deadline = Instant::now() + std::time::Duration::from_secs(10); - loop { - let waiting: bool = sqlx::query_scalar( - "SELECT EXISTS (\ - SELECT 1 FROM pg_stat_activity \ - WHERE pid = $1 \ - AND wait_event_type = 'Lock' \ - AND wait_event = 'advisory'\ - )", - ) - .bind(tx2_pid) - .fetch_one(&pool3) - .await - .expect("poll tx2 advisory-lock wait"); - if waiting { - break; - } - assert!( - Instant::now() < deadline, - "tx2 never entered advisory-lock wait — pg_advisory_xact_lock \ - must be present in the guard for the lock to serialize writers" - ); - tokio::time::sleep(std::time::Duration::from_millis(25)).await; - } - - // tx2 is observably blocked. Commit tx1, releasing the advisory lock. - sqlx::query("COMMIT") - .execute(&mut *conn1) - .await - .expect("tx1 COMMIT must succeed"); - - // tx2 unblocks: the trigger re-runs its SELECT MAX, sees committed 102, - // and INSERT 103 succeeds. Both the INSERT and COMMIT must complete. - let (insert2, commit2) = tx2_task.await.expect("tx2 task completed"); - insert2.expect("tx2 INSERT revision 103 must succeed after tx1 commits"); - commit2.expect("tx2 COMMIT must succeed"); - - // Both revisions 102 and 103 must be present (total: 1, 2, 100, 101, 102, 103). - let count: i64 = sqlx::query_scalar( - "SELECT count(*) FROM identity_enrollment_policies WHERE community_id = $1", - ) - .bind(community_id) - .fetch_one(&pool) - .await - .expect("count persisted policy revisions"); - assert_eq!( - count, 6, - "exactly six revisions must persist after the controlled concurrency sequence" - ); - } - - /// NIP-FI admission-result ↔ kind-11 receipt cardinality: a kind-11 - /// (protected-mutation) receipt must commit with exactly one admission - /// result; an admission result must commit against a kind-11 receipt. - /// - /// Mutation sensitivity is two-sided: - /// - the guard is load-bearing when a kind-11 receipt has no result row - /// (negative A) — without the guard this commits silently; - /// - the guard is load-bearing when a result attaches to a non-kind-11 - /// receipt (negative B) — without the guard this commits silently. - #[tokio::test] - #[ignore = "requires Postgres"] - async fn authorization_admission_result_requires_kind_11_receipt_bidirectional() { - let pool = connect_test_pool().await; - reset_public_schema(&pool).await; - MIGRATOR - .run_to(42, &pool) - .await - .expect("apply migrations 1-42"); - - let community_id = uuid::Uuid::new_v4(); - sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)") - .bind(community_id) - .bind(format!("adm-result-{}.example", community_id.simple())) - .execute(&pool) - .await - .expect("insert community"); - - // Capacity must exist for authorization_events inserts; admission-result - // tests exercise only authorization_operation_receipts and - // authorization_admission_results — no authorization_events rows are - // needed here, but insert capacity anyway to satisfy any trigger - // that reads the policy row defensively. - sqlx::query( - "INSERT INTO authorization_event_capacity \ - (community_id, max_events_per_domain, max_bytes_per_domain, max_envelope_bytes) \ - VALUES ($1, 1000, 16777216, 16384)", - ) - .bind(community_id) - .execute(&pool) - .await - .expect("insert event capacity"); - - let mut conn = pool.acquire().await.expect("acquire connection"); - - // --- Positive: kind-11 receipt + admission result in one transaction --- - let op1 = uuid::Uuid::new_v4(); - let fp1 = vec![0xB1_u8; 32]; - - sqlx::query("BEGIN") - .execute(&mut *conn) - .await - .expect("begin"); - - sqlx::query( - "INSERT INTO authorization_operation_receipts \ - (community_id, operation_id, request_fingerprint, operation_kind, \ - actor_fingerprint, outcome_code, result_digest) \ - VALUES ($1, $2, $3, 11, $4, 1, $5)", - ) - .bind(community_id) - .bind(op1) - .bind(&fp1) - .bind(vec![0xB2_u8; 32]) - .bind(vec![0xB3_u8; 32]) - .execute(&mut *conn) - .await - .expect("insert kind-11 receipt"); - - sqlx::query( - "INSERT INTO authorization_admission_results \ - (community_id, operation_id, request_fingerprint, semantic_fingerprint, \ - object_kind, object_key) \ - VALUES ($1, $2, $3, $4, 1, $5)", - ) - .bind(community_id) - .bind(op1) - .bind(&fp1) - .bind(vec![0xB4_u8; 32]) // semantic_fingerprint - .bind(vec![0xB5_u8; 32]) // object_key - .execute(&mut *conn) - .await - .expect("insert admission result"); - - sqlx::query("COMMIT") - .execute(&mut *conn) - .await - .expect("kind-11 receipt + result must commit"); - drop(conn); - - // --- Negative A: kind-11 receipt without result must be rejected --- - let op2 = uuid::Uuid::new_v4(); - let fp2 = vec![0xC1_u8; 32]; - - let mut conn_a = pool.acquire().await.expect("acquire connection A"); - sqlx::query("BEGIN") - .execute(&mut *conn_a) - .await - .expect("begin"); - - sqlx::query( - "INSERT INTO authorization_operation_receipts \ - (community_id, operation_id, request_fingerprint, operation_kind, \ - actor_fingerprint, outcome_code, result_digest) \ - VALUES ($1, $2, $3, 11, $4, 1, $5)", - ) - .bind(community_id) - .bind(op2) - .bind(&fp2) - .bind(vec![0xC2_u8; 32]) - .bind(vec![0xC3_u8; 32]) - .execute(&mut *conn_a) - .await - .expect("insert kind-11 receipt for negative A"); - - let no_result_err = sqlx::query("COMMIT") - .execute(&mut *conn_a) - .await - .expect_err("kind-11 receipt without result must be rejected at commit"); - assert!( - no_result_err - .as_database_error() - .map(|e| e.code().as_deref() == Some("23514")) - .unwrap_or(false), - "expected check_violation (23514) for kind-11 without result, got: {no_result_err}" - ); - drop(conn_a); - - // --- Negative B: admission result against non-kind-11 receipt --- - // Use operation_kind 12 (invalidation) — no admission result should - // ever attach to it. The guard fires at COMMIT (deferred trigger). - let op3 = uuid::Uuid::new_v4(); - let fp3 = vec![0xD1_u8; 32]; - - let mut conn_b = pool.acquire().await.expect("acquire connection B"); - sqlx::query("BEGIN") - .execute(&mut *conn_b) - .await - .expect("begin"); - - sqlx::query( - "INSERT INTO authorization_operation_receipts \ - (community_id, operation_id, request_fingerprint, operation_kind, \ - actor_fingerprint, outcome_code, result_digest) \ - VALUES ($1, $2, $3, 12, $4, 1, $5)", - ) - .bind(community_id) - .bind(op3) - .bind(&fp3) - .bind(vec![0xD2_u8; 32]) - .bind(vec![0xD3_u8; 32]) - .execute(&mut *conn_b) - .await - .expect("insert kind-12 receipt"); - - // The guard is deferred: the INSERT succeeds; the violation surfaces - // at COMMIT when the guard checks that the receipt is kind-11. - sqlx::query( - "INSERT INTO authorization_admission_results \ - (community_id, operation_id, request_fingerprint, semantic_fingerprint, \ - object_kind, object_key) \ - VALUES ($1, $2, $3, $4, 1, $5)", - ) - .bind(community_id) - .bind(op3) - .bind(&fp3) - .bind(vec![0xD4_u8; 32]) - .bind(vec![0xD5_u8; 32]) - .execute(&mut *conn_b) - .await - .expect("result insert must pass — deferred guard fires at commit, not here"); - - let wrong_kind_err = sqlx::query("COMMIT") - .execute(&mut *conn_b) - .await - .expect_err("result against non-kind-11 receipt must be rejected at commit"); - assert!( - wrong_kind_err - .as_database_error() - .map(|e| e.code().as_deref() == Some("23514")) - .unwrap_or(false), - "expected check_violation (23514) for result against non-kind-11 receipt, got: {wrong_kind_err}" - ); - drop(conn_b); - - // --- Negative C: mismatched request_fingerprint rejected by composite FK --- - // The admission result table has an immediate composite FK - // (community_id, operation_id, request_fingerprint) - // REFERENCES authorization_operation_receipts(...) - // A result referencing a receipt that exists but with a different - // request_fingerprint must be rejected. This exercises the semantic half - // of Carl finding 2 — cardinality is handled by the deferred trigger; - // coordinate binding is handled by the structural FK. - let op4 = uuid::Uuid::new_v4(); - let fp4_receipt = vec![0xE1_u8; 32]; // fingerprint stored in the receipt - let fp4_wrong = vec![0xE2_u8; 32]; // wrong fingerprint used in the result - - let mut conn_c = pool.acquire().await.expect("acquire connection C"); - sqlx::query("BEGIN") - .execute(&mut *conn_c) - .await - .expect("begin"); - - sqlx::query( - "INSERT INTO authorization_operation_receipts \ - (community_id, operation_id, request_fingerprint, operation_kind, \ - actor_fingerprint, outcome_code, result_digest) \ - VALUES ($1, $2, $3, 11, $4, 1, $5)", - ) - .bind(community_id) - .bind(op4) - .bind(&fp4_receipt) - .bind(vec![0xE3_u8; 32]) - .bind(vec![0xE4_u8; 32]) - .execute(&mut *conn_c) - .await - .expect("insert kind-11 receipt for negative C"); - - // The admission result FK is immediate (not deferred), so the INSERT - // itself rejects a fingerprint with no matching receipt row. - let wrong_fp_err = sqlx::query( - "INSERT INTO authorization_admission_results \ - (community_id, operation_id, request_fingerprint, semantic_fingerprint, \ - object_kind, object_key) \ - VALUES ($1, $2, $3, $4, 1, $5)", - ) - .bind(community_id) - .bind(op4) - .bind(&fp4_wrong) // wrong fingerprint — no matching receipt row - .bind(vec![0xE5_u8; 32]) - .bind(vec![0xE6_u8; 32]) - .execute(&mut *conn_c) - .await - .expect_err("result with mismatched request_fingerprint must be rejected at INSERT"); - // Immediate composite FK fires as foreign_key_violation (23503). - assert!( - wrong_fp_err - .as_database_error() - .map(|e| e.code().as_deref() == Some("23503")) - .unwrap_or(false), - "expected foreign_key_violation (23503) for mismatched request_fingerprint, got: {wrong_fp_err}" - ); - sqlx::query("ROLLBACK").execute(&mut *conn_c).await.ok(); - } - - /// NIP-FI denial-attempt ↔ kind-9 event cardinality: a kind-9 - /// (pre-authentication denial) audit event must commit with exactly one - /// denial attempt; a denial attempt must commit with a matching kind-9 - /// audit event. - /// - /// Mutation sensitivity is two-sided: - /// - the event-side guard is load-bearing when a kind-9 event has no - /// attempt row (negative A) — without it this commits silently, making - /// replay reconstruction impossible; - /// - the attempt-side guard is load-bearing for semantic mismatches (negatives - /// B1–B3) — the old deferred FK only checks event existence/kind and would - /// not catch a correlation, reason_code, or attempt_id mismatch. - #[tokio::test] - #[ignore = "requires Postgres"] - async fn authorization_denial_attempt_requires_kind_9_event_bidirectional() { - let pool = connect_test_pool().await; - reset_public_schema(&pool).await; - MIGRATOR - .run_to(42, &pool) - .await - .expect("apply migrations 1-42"); - - let community_id = uuid::Uuid::new_v4(); - sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)") - .bind(community_id) - .bind(format!("denial-attempt-{}.example", community_id.simple())) - .execute(&pool) - .await - .expect("insert community"); - - // Capacity is required by the authorization_events BEFORE INSERT trigger. - sqlx::query( - "INSERT INTO authorization_event_capacity \ - (community_id, max_events_per_domain, max_bytes_per_domain, max_envelope_bytes) \ - VALUES ($1, 1000, 16777216, 16384)", - ) - .bind(community_id) - .execute(&pool) - .await - .expect("insert event capacity"); - - let mut conn = pool.acquire().await.expect("acquire connection"); - - // --- Positive: kind-9 event + denial attempt in one transaction --- - let op1 = uuid::Uuid::new_v4(); - let event1 = uuid::Uuid::new_v4(); - let corr1 = uuid::Uuid::new_v4(); - let attempt1_id = uuid::Uuid::new_v4(); - - sqlx::query("BEGIN") - .execute(&mut *conn) - .await - .expect("begin"); - - // Insert denial attempt first (FKs are deferred). - sqlx::query( - "INSERT INTO authorization_authentication_denial_attempts \ - (community_id, operation_id, correlation_id, semantic_fingerprint, \ - denial_reason, expected_revision, action, reason_code, \ - attempt_id, audit_event_id, audit_event_kind) \ - VALUES ($1, $2, $3, $4, 1, 1, 1, 2, $5, $6, 9)", - ) - .bind(community_id) - .bind(op1) - .bind(corr1) - .bind(vec![0xE1_u8; 32]) // semantic_fingerprint - .bind(attempt1_id) - .bind(event1) - .execute(&mut *conn) - .await - .expect("insert denial attempt before event"); - - // Insert the kind-9 event (actor_kind 4, no request_fingerprint). - sqlx::query( - "INSERT INTO authorization_events \ - (community_id, event_id, event_kind, outcome_code, reason_code, \ - actor_kind, operation_id, correlation_id, attempt_id, \ - semantic_fingerprint, occurred_at, canonical_envelope, envelope_digest) \ - VALUES ($1, $2, 9, 2, 2, 4, $3, $4, $5, \ - $6, '2026-01-01T00:00:00Z', $7, $8)", - ) - .bind(community_id) - .bind(event1) - .bind(op1) - .bind(corr1) - .bind(attempt1_id) - .bind(vec![0xE1_u8; 32]) // semantic_fingerprint matches denial attempt - .bind(vec![0xE2_u8; 64]) // canonical_envelope (≤16384 bytes) - .bind(vec![0xE3_u8; 32]) // envelope_digest - .execute(&mut *conn) - .await - .expect("insert kind-9 event"); - - sqlx::query("COMMIT") - .execute(&mut *conn) - .await - .expect("kind-9 event + denial attempt must commit"); - drop(conn); - - // --- Negative A: kind-9 event alone must be rejected at commit --- - let op2 = uuid::Uuid::new_v4(); - let event2 = uuid::Uuid::new_v4(); - let corr2 = uuid::Uuid::new_v4(); - let attempt2_id = uuid::Uuid::new_v4(); - - let mut conn_a = pool.acquire().await.expect("acquire connection A"); - sqlx::query("BEGIN") - .execute(&mut *conn_a) - .await - .expect("begin"); - - sqlx::query( - "INSERT INTO authorization_events \ - (community_id, event_id, event_kind, outcome_code, reason_code, \ - actor_kind, operation_id, correlation_id, attempt_id, \ - semantic_fingerprint, occurred_at, canonical_envelope, envelope_digest) \ - VALUES ($1, $2, 9, 2, 1, 4, $3, $4, $5, \ - $6, '2026-01-01T00:00:00Z', $7, $8)", - ) - .bind(community_id) - .bind(event2) - .bind(op2) - .bind(corr2) - .bind(attempt2_id) - .bind(vec![0xF0_u8; 32]) // semantic_fingerprint (non-zero) - .bind(vec![0xF1_u8; 64]) - .bind(vec![0xF2_u8; 32]) - .execute(&mut *conn_a) - .await - .expect("insert kind-9 event without attempt"); - - let no_attempt_err = sqlx::query("COMMIT") - .execute(&mut *conn_a) - .await - .expect_err("kind-9 event without denial attempt must be rejected at commit"); - assert!( - no_attempt_err - .as_database_error() - .map(|e| e.code().as_deref() == Some("23514")) - .unwrap_or(false), - "expected check_violation (23514) for kind-9 event without attempt, got: {no_attempt_err}" - ); - drop(conn_a); - - // --- Negatives B1-B3: semantic coordinate mismatches, each attributed to - // the named guard (23514), not the old deferred FK (23503). Each case - // inserts a valid event then a denial attempt that matches everywhere - // except one coordinate; the guard must fire for that mismatch. - - // B1: correlation_id mismatch — attempt carries a different correlation - // than the event it references. - let op_b1 = uuid::Uuid::new_v4(); - let event_b1 = uuid::Uuid::new_v4(); - let corr_b1_event = uuid::Uuid::new_v4(); - let corr_b1_wrong = uuid::Uuid::new_v4(); // different from corr_b1_event - let attempt_b1 = uuid::Uuid::new_v4(); - - let mut conn_b1 = pool.acquire().await.expect("acquire connection B1"); - sqlx::query("BEGIN") - .execute(&mut *conn_b1) - .await - .expect("begin B1"); - - // Insert the event first (deferred FK allows this ordering). - sqlx::query( - "INSERT INTO authorization_events \ - (community_id, event_id, event_kind, outcome_code, reason_code, \ - actor_kind, operation_id, correlation_id, attempt_id, \ - semantic_fingerprint, occurred_at, canonical_envelope, envelope_digest) \ - VALUES ($1, $2, 9, 2, 2, 4, $3, $4, $5, \ - $6, '2026-01-01T00:00:00Z', $7, $8)", - ) - .bind(community_id) - .bind(event_b1) - .bind(op_b1) - .bind(corr_b1_event) - .bind(attempt_b1) - .bind(vec![0xB3_u8; 32]) // semantic_fingerprint matches denial attempt - .bind(vec![0xB1_u8; 64]) - .bind(vec![0xB2_u8; 32]) - .execute(&mut *conn_b1) - .await - .expect("insert kind-9 event for B1"); - - sqlx::query( - "INSERT INTO authorization_authentication_denial_attempts \ - (community_id, operation_id, correlation_id, semantic_fingerprint, \ - denial_reason, expected_revision, action, reason_code, \ - attempt_id, audit_event_id, audit_event_kind) \ - VALUES ($1, $2, $3, $4, 1, 1, 1, 2, $5, $6, 9)", - ) - .bind(community_id) - .bind(op_b1) - .bind(corr_b1_wrong) // wrong correlation_id - .bind(vec![0xB3_u8; 32]) - .bind(attempt_b1) - .bind(event_b1) - .execute(&mut *conn_b1) - .await - .expect("insert denial attempt with wrong correlation_id (guard deferred)"); - - let corr_err = sqlx::query("COMMIT") - .execute(&mut *conn_b1) - .await - .expect_err("mismatched correlation_id must be rejected at commit"); - assert!( - corr_err - .as_database_error() - .map(|e| e.code().as_deref() == Some("23514")) - .unwrap_or(false), - "expected check_violation (23514) from authorization_denial_attempt_semantic_binding \ - for correlation_id mismatch, got: {corr_err}" - ); - drop(conn_b1); - - // B2: reason_code mismatch — attempt carries denial_reason=1 (MissingCredential, - // requires reason_code=2 per canonical mapping), but event carries reason_code=1. - // The attempt INSERT passes (denial_reason=1↔reason_code=2 is a valid mapping pair), - // then the deferred guard fires at commit because event reason_code=1 ≠ attempt - // reason_code=2. - let op_b2 = uuid::Uuid::new_v4(); - let event_b2 = uuid::Uuid::new_v4(); - let corr_b2 = uuid::Uuid::new_v4(); - let attempt_b2 = uuid::Uuid::new_v4(); - - let mut conn_b2 = pool.acquire().await.expect("acquire connection B2"); - sqlx::query("BEGIN") - .execute(&mut *conn_b2) - .await - .expect("begin B2"); - - sqlx::query( - "INSERT INTO authorization_events \ - (community_id, event_id, event_kind, outcome_code, reason_code, \ - actor_kind, operation_id, correlation_id, attempt_id, \ - semantic_fingerprint, occurred_at, canonical_envelope, envelope_digest) \ - VALUES ($1, $2, 9, 2, 1, 4, $3, $4, $5, \ - $6, '2026-01-01T00:00:00Z', $7, $8)", - ) - .bind(community_id) - .bind(event_b2) - .bind(op_b2) - .bind(corr_b2) - .bind(attempt_b2) - .bind(vec![0xC3_u8; 32]) // semantic_fingerprint matches denial attempt - .bind(vec![0xC1_u8; 64]) - .bind(vec![0xC2_u8; 32]) - .execute(&mut *conn_b2) - .await - .expect("insert kind-9 event for B2 (reason_code=1)"); - - sqlx::query( - "INSERT INTO authorization_authentication_denial_attempts \ - (community_id, operation_id, correlation_id, semantic_fingerprint, \ - denial_reason, expected_revision, action, reason_code, \ - attempt_id, audit_event_id, audit_event_kind) \ - VALUES ($1, $2, $3, $4, 1, 1, 1, 2, $5, $6, 9)", - // reason_code = 2 but event has reason_code = 1 - ) - .bind(community_id) - .bind(op_b2) - .bind(corr_b2) - .bind(vec![0xC3_u8; 32]) - .bind(attempt_b2) - .bind(event_b2) - .execute(&mut *conn_b2) - .await - .expect( - "insert denial attempt with wrong reason_code (deferred guard will fire at commit)", - ); - - let reason_err = sqlx::query("COMMIT") - .execute(&mut *conn_b2) - .await - .expect_err("mismatched reason_code must be rejected at commit"); - assert!( - reason_err - .as_database_error() - .map(|e| e.code().as_deref() == Some("23514")) - .unwrap_or(false), - "expected check_violation (23514) from authorization_denial_attempt_semantic_binding \ - for reason_code mismatch, got: {reason_err}" - ); - drop(conn_b2); - - // B3: attempt_id mismatch — the denial attempt's attempt_id FK references - // a different event (attempt_b3_wrong) than the one being paired (event_b3). - // The attempt_id FK on the denial attempt table binds - // (community_id, operation_id, audit_event_kind, attempt_id) - // -> authorization_events(community_id, operation_id, event_kind, attempt_id) - // so using a different attempt_id that doesn't exist for this operation - // will be caught as a FK violation (23503) at commit. - let op_b3 = uuid::Uuid::new_v4(); - let event_b3 = uuid::Uuid::new_v4(); - let corr_b3 = uuid::Uuid::new_v4(); - let attempt_b3_correct = uuid::Uuid::new_v4(); - let attempt_b3_wrong = uuid::Uuid::new_v4(); // not registered for this operation - - let mut conn_b3 = pool.acquire().await.expect("acquire connection B3"); - sqlx::query("BEGIN") - .execute(&mut *conn_b3) - .await - .expect("begin B3"); - - sqlx::query( - "INSERT INTO authorization_events \ - (community_id, event_id, event_kind, outcome_code, reason_code, \ - actor_kind, operation_id, correlation_id, attempt_id, \ - semantic_fingerprint, occurred_at, canonical_envelope, envelope_digest) \ - VALUES ($1, $2, 9, 2, 2, 4, $3, $4, $5, \ - $6, '2026-01-01T00:00:00Z', $7, $8)", - ) - .bind(community_id) - .bind(event_b3) - .bind(op_b3) - .bind(corr_b3) - .bind(attempt_b3_correct) - .bind(vec![0xD3_u8; 32]) // semantic_fingerprint (matches denial attempt) - .bind(vec![0xD1_u8; 64]) - .bind(vec![0xD2_u8; 32]) - .execute(&mut *conn_b3) - .await - .expect("insert kind-9 event for B3"); - - sqlx::query( - "INSERT INTO authorization_authentication_denial_attempts \ - (community_id, operation_id, correlation_id, semantic_fingerprint, \ - denial_reason, expected_revision, action, reason_code, \ - attempt_id, audit_event_id, audit_event_kind) \ - VALUES ($1, $2, $3, $4, 1, 1, 1, 2, $5, $6, 9)", - ) - .bind(community_id) - .bind(op_b3) - .bind(corr_b3) - .bind(vec![0xD3_u8; 32]) - .bind(attempt_b3_wrong) // wrong attempt_id — no matching UNIQUE row on events - .bind(event_b3) - .execute(&mut *conn_b3) - .await - .expect("insert denial attempt with wrong attempt_id (FK is deferred)"); - - let attempt_err = sqlx::query("COMMIT") - .execute(&mut *conn_b3) - .await - .expect_err("mismatched attempt_id must be rejected at commit"); - // The attempt_id FK is deferred and fires as foreign_key_violation (23503). - assert!( - attempt_err - .as_database_error() - .map(|e| e.code().as_deref() == Some("23503")) - .unwrap_or(false), - "expected foreign_key_violation (23503) for attempt_id mismatch \ - (deferred FK on denial attempt), got: {attempt_err}" - ); - - // B4: denial_reason ↔ reason_code mapping violation — the denial attempt - // row carries denial_reason=2 (InvalidCredential) but reason_code=2 - // (Missing). The canonical mapping requires InvalidCredential(2)↔Invalid(3); - // reason_code=2 is only valid for MissingCredential(denial_reason=1). - // The immediate CHECK constraint authorization_denial_reason_reason_code_binding - // fires at INSERT, not commit. Mutation-sensitive: removing the CHECK lets - // this INSERT succeed (the guard does not compare denial_reason; only the - // paired event's reason_code is checked at commit). - let op_b4 = uuid::Uuid::new_v4(); - let event_b4 = uuid::Uuid::new_v4(); - let corr_b4 = uuid::Uuid::new_v4(); - let attempt_b4 = uuid::Uuid::new_v4(); - - let mut conn_b4 = pool.acquire().await.expect("acquire connection B4"); - sqlx::query("BEGIN") - .execute(&mut *conn_b4) - .await - .expect("begin B4"); - - // Insert the matching kind-9 event first. - sqlx::query( - "INSERT INTO authorization_events \ - (community_id, event_id, event_kind, outcome_code, reason_code, \ - actor_kind, operation_id, correlation_id, attempt_id, \ - semantic_fingerprint, occurred_at, canonical_envelope, envelope_digest) \ - VALUES ($1, $2, 9, 2, 2, 4, $3, $4, $5, \ - $6, '2026-01-01T00:00:00Z', $7, $8)", - ) - .bind(community_id) - .bind(event_b4) - .bind(op_b4) - .bind(corr_b4) - .bind(attempt_b4) - .bind(vec![0xE4_u8; 32]) // semantic_fingerprint - .bind(vec![0xE5_u8; 64]) - .bind(vec![0xE6_u8; 32]) - .execute(&mut *conn_b4) - .await - .expect("insert kind-9 event for B4"); - - // Insert denial attempt with denial_reason=2 (InvalidCredential) but - // reason_code=2 (Missing) — violates the canonical mapping (requires reason_code=3). - let denial_reason_err = sqlx::query( - "INSERT INTO authorization_authentication_denial_attempts \ - (community_id, operation_id, correlation_id, semantic_fingerprint, \ - denial_reason, expected_revision, action, reason_code, \ - attempt_id, audit_event_id, audit_event_kind) \ - VALUES ($1, $2, $3, $4, 2, 1, 1, 2, $5, $6, 9)", - // denial_reason=2 (InvalidCredential) requires reason_code=3; reason_code=2 is wrong - ) - .bind(community_id) - .bind(op_b4) - .bind(corr_b4) - .bind(vec![0xE4_u8; 32]) - .bind(attempt_b4) - .bind(event_b4) - .execute(&mut *conn_b4) - .await - .expect_err("denial_reason/reason_code mapping violation must be rejected at INSERT"); - - assert!( - denial_reason_err - .as_database_error() - .map(|e| e.code().as_deref() == Some("23514")) - .unwrap_or(false), - "expected check_violation (23514) from \ - authorization_denial_reason_reason_code_binding for denial_reason mismatch, \ - got: {denial_reason_err}" - ); - - // B5: semantic_fingerprint mismatch — the event carries semantic_fingerprint - // 0xB5…B5 while the denial attempt carries 0xB6…B6. correlation_id, reason_code, - // and attempt_id all match; only the fingerprint differs. The deferred guard - // authorization_denial_attempt_guard_v1 fires at COMMIT on the denial-attempt - // side, compares found_semantic_fingerprint (from the event) with - // NEW.semantic_fingerprint (from the attempt), and raises 23514 with named - // constraint authorization_denial_attempt_semantic_binding. - // Mutation-sensitive: removing the semantic_fingerprint comparison block from - // the guard function lets this transaction commit. - let op_b5 = uuid::Uuid::new_v4(); - let event_b5 = uuid::Uuid::new_v4(); - let corr_b5 = uuid::Uuid::new_v4(); - let attempt_b5 = uuid::Uuid::new_v4(); - let fp_event_b5 = vec![0xB5_u8; 32]; // event semantic_fingerprint - let fp_attempt_b5 = vec![0xB6_u8; 32]; // mismatched attempt semantic_fingerprint - - let mut conn_b5 = pool.acquire().await.expect("acquire connection B5"); - sqlx::query("BEGIN") - .execute(&mut *conn_b5) - .await - .expect("begin B5"); - - // Insert the kind-9 event with fingerprint 0xB5…B5. - sqlx::query( - "INSERT INTO authorization_events \ - (community_id, event_id, event_kind, outcome_code, reason_code, \ - actor_kind, operation_id, correlation_id, attempt_id, \ - semantic_fingerprint, occurred_at, canonical_envelope, envelope_digest) \ - VALUES ($1, $2, 9, 2, 2, 4, $3, $4, $5, \ - $6, '2026-01-01T00:00:00Z', $7, $8)", - ) - .bind(community_id) - .bind(event_b5) - .bind(op_b5) - .bind(corr_b5) - .bind(attempt_b5) - .bind(fp_event_b5) - .bind(vec![0xB7_u8; 64]) // canonical_envelope - .bind(vec![0xB8_u8; 32]) // envelope_digest - .execute(&mut *conn_b5) - .await - .expect("insert kind-9 event for B5"); - - // Insert denial attempt with the WRONG semantic_fingerprint (0xB6…B6). - // correlation_id, reason_code=2, denial_reason=1 (MissingCredential↔Missing), - // and attempt_id all match the event — only semantic_fingerprint differs. - sqlx::query( - "INSERT INTO authorization_authentication_denial_attempts \ - (community_id, operation_id, correlation_id, semantic_fingerprint, \ - denial_reason, expected_revision, action, reason_code, \ - attempt_id, audit_event_id, audit_event_kind) \ - VALUES ($1, $2, $3, $4, 1, 1, 1, 2, $5, $6, 9)", - ) - .bind(community_id) - .bind(op_b5) - .bind(corr_b5) - .bind(fp_attempt_b5) // 0xB6…B6 ≠ event's 0xB5…B5 - .bind(attempt_b5) - .bind(event_b5) - .execute(&mut *conn_b5) - .await - .expect( - "insert denial attempt with mismatched fingerprint (deferred guard fires at commit)", - ); - - let fp_mismatch_err = sqlx::query("COMMIT") - .execute(&mut *conn_b5) - .await - .expect_err("commit with mismatched semantic_fingerprint must be rejected"); - - assert!( - fp_mismatch_err - .as_database_error() - .map(|e| e.code().as_deref() == Some("23514")) - .unwrap_or(false), - "expected check_violation (23514) from \ - authorization_denial_attempt_semantic_binding for semantic_fingerprint mismatch, \ - got: {fp_mismatch_err}" - ); - } - - /// NIP-FI authenticated kind-9 OperatorDenied denial: an authenticated - /// kind-9 event (actor_kind 1–3, non-null request_fingerprint) must commit - /// without an authorization_authentication_denial_attempts row and must - /// reject any attempt to attach one. - /// - /// Mutation sensitivity: - /// - Removing the `actor_kind <> 4` guard from the event-side trigger makes - /// positive A red: the COMMIT fails because the guard now requires a - /// denial-attempt row for the authenticated event and none is present. - /// - Removing the `actor_kind <> 4` shape guard from the attempt-side - /// trigger makes negative B red: the COMMIT is rejected by the pre-existing - /// `authorization_denial_attempt_semantic_binding` guard instead (non-null - /// attempt `semantic_fingerprint` vs. null on the authenticated event), so - /// `assert_eq!` on the constraint name fails. The exact constraint name - /// assertion is therefore the load-bearing proof that the new shape guard — - /// not the pre-existing semantic-binding check — is what fires. - /// - /// The unresolved pre-auth positive path (actor_kind 4) is exercised in - /// `authorization_denial_attempt_requires_kind_9_event_bidirectional` and - /// is unchanged by this fix. - #[tokio::test] - #[ignore = "requires Postgres"] - async fn authenticated_kind_9_denial_commits_without_denial_attempt() { - let pool = connect_test_pool().await; - reset_public_schema(&pool).await; - MIGRATOR - .run_to(42, &pool) - .await - .expect("apply migrations 1-42"); - - let community_id = uuid::Uuid::new_v4(); - sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)") - .bind(community_id) - .bind(format!("auth-denial-{}.example", community_id.simple())) - .execute(&pool) - .await - .expect("insert community"); - - sqlx::query( - "INSERT INTO authorization_event_capacity \ - (community_id, max_events_per_domain, max_bytes_per_domain, max_envelope_bytes) \ - VALUES ($1, 1000, 16777216, 16384)", - ) - .bind(community_id) - .execute(&pool) - .await - .expect("insert event capacity"); - - // Seed an operation receipt for the authenticated denial. Use - // operation_kind = 12 (invalidation) with outcome_code = 2 (denied): - // this satisfies the receipt CHECK constraints without triggering the - // lifecycle history guard (expected_count = 0 for non-lifecycle kinds) - // and without requiring a lifecycle event (expected_event_kind = NULL). - // The authorization_events FK on (community_id, operation_id, - // request_fingerprint) requires a receipt row. - let op_auth = uuid::Uuid::new_v4(); - let fp_auth = vec![0xA1_u8; 32]; - - sqlx::query( - "INSERT INTO authorization_operation_receipts \ - (community_id, operation_id, request_fingerprint, operation_kind, \ - actor_fingerprint, outcome_code, result_digest) \ - VALUES ($1, $2, $3, 12, $4, 2, $5)", - // operation_kind 12 (invalidation), outcome_code 2 (denied) - ) - .bind(community_id) - .bind(op_auth) - .bind(&fp_auth) - .bind(vec![0xA2_u8; 32]) // actor_fingerprint - .bind(vec![0xA3_u8; 32]) // result_digest - .execute(&pool) - .await - .expect("seed authenticated denial receipt"); - - let event_auth = uuid::Uuid::new_v4(); - let corr_auth = uuid::Uuid::new_v4(); - let attempt_auth = uuid::Uuid::new_v4(); - - // --- Positive A: authenticated kind-9 denial (actor_kind = 1) commits - // without any denial-attempt row. The semantic_fingerprint must be NULL - // per the corrected shape CHECK. The deferred cardinality guard must - // skip this event because actor_kind ≠ 4. - let mut conn = pool.acquire().await.expect("acquire connection"); - sqlx::query("BEGIN") - .execute(&mut *conn) - .await - .expect("begin"); - - sqlx::query( - "INSERT INTO authorization_events \ - (community_id, event_id, event_kind, outcome_code, reason_code, \ - actor_kind, actor_fingerprint, operation_id, request_fingerprint, \ - correlation_id, attempt_id, semantic_fingerprint, \ - occurred_at, canonical_envelope, envelope_digest) \ - VALUES ($1, $2, 9, 2, 4, 1, $3, $4, $5, $6, $7, NULL, \ - '2026-01-01T00:00:00Z', $8, $9)", - ) - .bind(community_id) - .bind(event_auth) - .bind(vec![0xA4_u8; 32]) // actor_fingerprint (required for actor_kind 1) - .bind(op_auth) - .bind(&fp_auth) // non-null request_fingerprint (authenticated shape) - .bind(corr_auth) - .bind(attempt_auth) - // semantic_fingerprint = NULL: authenticated kind-9 must not carry one - .bind(vec![0xA5_u8; 64]) // canonical_envelope - .bind(vec![0xA6_u8; 32]) // envelope_digest - .execute(&mut *conn) - .await - .expect("insert authenticated kind-9 event"); - - sqlx::query("COMMIT").execute(&mut *conn).await.expect( - "authenticated kind-9 denial must commit without a denial-attempt row \ - — the event-side cardinality guard must skip actor_kind 1", - ); - drop(conn); - - // Confirm no denial attempt was needed: the table must have zero rows - // for this event. - let attempt_count: i64 = sqlx::query_scalar( - "SELECT count(*) FROM authorization_authentication_denial_attempts \ - WHERE community_id = $1 AND audit_event_id = $2", - ) - .bind(community_id) - .bind(event_auth) - .fetch_one(&pool) - .await - .expect("count denial attempts for authenticated event"); - assert_eq!( - attempt_count, 0, - "no denial-attempt row should exist for an authenticated kind-9 event" - ); - - // --- Negative B: a denial attempt cannot bind to the authenticated kind-9 - // event. The attempt-side shape guard must reject this at commit because - // the referenced event has actor_kind = 1 (not 4). The rejection must - // name the exact shape constraint (authorization_denial_attempt_event_kind) - // rather than merely returning 23514, proving the new actor/request-fingerprint - // guard fires — not the pre-existing semantic_fingerprint equality check - // (which would fire as authorization_denial_attempt_semantic_binding if - // the shape guard were absent, because the attempt carries a non-null - // semantic_fingerprint while the authenticated event has null). - // - // Reuse attempt_auth from the committed event so the deferred attempt_id - // FK resolves (wrong attempt_id would activate that FK first and make the - // negative non-isolated to the new guard). - let mut conn_b = pool.acquire().await.expect("acquire connection B"); - sqlx::query("BEGIN") - .execute(&mut *conn_b) - .await - .expect("begin B"); - - // Insert the denial attempt referencing the authenticated event. - // The attempt table FKs are deferred, so this INSERT succeeds; - // the shape guard fires at COMMIT. - sqlx::query( - "INSERT INTO authorization_authentication_denial_attempts \ - (community_id, operation_id, correlation_id, semantic_fingerprint, \ - denial_reason, expected_revision, action, reason_code, \ - attempt_id, audit_event_id, audit_event_kind) \ - VALUES ($1, $2, $3, $4, 1, 1, 1, 2, $5, $6, 9)", - ) - .bind(community_id) - .bind(op_auth) - .bind(corr_auth) - .bind(vec![0xA7_u8; 32]) // semantic_fingerprint on the attempt (non-null) - .bind(attempt_auth) // reuse the event's attempt_id — FK isolation - .bind(event_auth) // references the authenticated event (actor_kind = 1) - .execute(&mut *conn_b) - .await - .expect("attempt INSERT must pass — shape guard is deferred"); - - let cross_shape_err = sqlx::query("COMMIT") - .execute(&mut *conn_b) - .await - .expect_err( - "denial attempt binding to authenticated kind-9 event must be rejected at commit", - ); - // The exact constraint name must be authorization_denial_attempt_event_kind — - // the new actor/request_fingerprint shape guard. If the shape guard were - // removed, the pre-existing semantic_fingerprint equality check would fire - // instead, named authorization_denial_attempt_semantic_binding. Requiring - // the exact name makes the mutation reliably red. - assert_eq!( - cross_shape_err - .as_database_error() - .and_then(|e| e.constraint()), - Some("authorization_denial_attempt_event_kind"), - "rejection must be attributed to authorization_denial_attempt_event_kind \ - shape guard (not an incidental FK or semantic-binding check), \ - got: {cross_shape_err}" - ); - } - - /// NIP-FI denied lifecycle receipt: a denied core lifecycle receipt - /// (outcome_code = 2) must commit without a paired audit event. Requiring - /// one would falsely record that the lifecycle transition occurred. - /// - /// The denied branch forbids any event from the complete core - /// success-transition class (kinds 1, 2, 3, 6). This test uses the mapped - /// kind (kind 1 for enroll). Cross-kind rejection — a wrong success-transition - /// kind on a denied receipt — is exercised by - /// `denied_lifecycle_receipt_wrong_kind_receipt_side` (receipt-side trigger) - /// and `denied_lifecycle_receipt_wrong_kind_event_side` (event-side trigger). - /// - /// Mutation sensitivity: - /// - Removing the `outcome_code IN (1, 3)` branch entirely (or replacing it with a - /// blanket early-return) makes the positive case red — the denied enroll receipt - /// cannot commit alone because the guard then demands a paired enroll audit event - /// (expected_event_kind = 1) that is absent. - /// - Removing the `ELSIF outcome_code = 2` zero-event branch makes the - /// receipt-then-event negative below green (COMMIT succeeds when it must not), - /// failing `expect_err`. The event-side isolation in - /// `denied_lifecycle_receipt_event_side_trigger_isolated` independently confirms - /// the same branch using only the `authorization_event_receipt_cardinality` - /// trigger direction. - /// Applied/no-op lifecycle cardinality is exercised by - /// `applied_lifecycle_receipt_requires_exactly_one_event`. - #[tokio::test] - #[ignore = "requires Postgres"] - async fn denied_lifecycle_receipt_commits_without_audit_event() { - let pool = connect_test_pool().await; - reset_public_schema(&pool).await; - MIGRATOR - .run_to(42, &pool) - .await - .expect("apply migrations 1-42"); - - let community_id = uuid::Uuid::new_v4(); - sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)") - .bind(community_id) - .bind(format!( - "denied-lifecycle-{}.example", - community_id.simple() - )) - .execute(&pool) - .await - .expect("insert community"); - - // Denied enroll receipt (operation_kind = 1, outcome_code = 2) must commit - // without any paired authorization_events row. The guard must skip it - // because outcome_code = 2 is not in (1, 3). - // - // The receipt history guard (migration 0041) uses `outcome_code IN (1, 3)` - // for lifecycle receipts, so a denied enroll receipt (outcome_code = 2) - // expects zero lifecycle history rows — no history setup is needed. - let op_denied = uuid::Uuid::new_v4(); - let fp_denied = vec![0xB1_u8; 32]; - - sqlx::query( - "INSERT INTO authorization_operation_receipts \ - (community_id, operation_id, request_fingerprint, operation_kind, \ - actor_fingerprint, outcome_code, result_digest) \ - VALUES ($1, $2, $3, 1, $4, 2, $5)", - // operation_kind 1 (enroll), outcome_code 2 (denied) - ) - .bind(community_id) - .bind(op_denied) - .bind(&fp_denied) - .bind(vec![0xB2_u8; 32]) - .bind(vec![0xB3_u8; 32]) - .execute(&pool) - .await - .expect("denied enroll receipt must commit without a paired audit event"); - - // No audit event for this operation; confirm the table is empty for it. - let event_count: i64 = sqlx::query_scalar( - "SELECT count(*) FROM authorization_events \ - WHERE community_id = $1 AND operation_id = $2", - ) - .bind(community_id) - .bind(op_denied) - .fetch_one(&pool) - .await - .expect("count events for denied receipt"); - assert_eq!( - event_count, 0, - "no audit event should be required or present for a denied lifecycle receipt" - ); - - // --- Negative: denied enroll receipt paired with its mapped success- - // transition event (event_kind = 1, enrolled) must be rejected at COMMIT. - // The receipt-side deferred trigger fires here (receipt was inserted in - // this same transaction). The event-side trigger direction is isolated in - // `denied_lifecycle_receipt_event_side_trigger_isolated`. - // - // Seed event capacity; the authorization_events BEFORE INSERT trigger - // requires a capacity row. No lifecycle history is needed: denied receipts - // (outcome_code = 2) expect zero history rows per the history guard. - sqlx::query( - "INSERT INTO authorization_event_capacity \ - (community_id, max_events_per_domain, max_bytes_per_domain, max_envelope_bytes) \ - VALUES ($1, 1000, 16777216, 16384)", - ) - .bind(community_id) - .execute(&pool) - .await - .expect("insert event capacity"); - - let op_neg = uuid::Uuid::new_v4(); - let fp_neg = vec![0xD1_u8; 32]; - let event_neg = uuid::Uuid::new_v4(); - let corr_neg = uuid::Uuid::new_v4(); - let attempt_neg = uuid::Uuid::new_v4(); - - let mut conn_neg = pool.acquire().await.expect("acquire connection neg"); - sqlx::query("BEGIN") - .execute(&mut *conn_neg) - .await - .expect("begin neg"); - - // Denied enroll receipt — no history row needed (outcome_code = 2). - sqlx::query( - "INSERT INTO authorization_operation_receipts \ - (community_id, operation_id, request_fingerprint, operation_kind, \ - actor_fingerprint, outcome_code, result_digest) \ - VALUES ($1, $2, $3, 1, $4, 2, $5)", - ) - .bind(community_id) - .bind(op_neg) - .bind(&fp_neg) - .bind(vec![0xD2_u8; 32]) - .bind(vec![0xD3_u8; 32]) - .execute(&mut *conn_neg) - .await - .expect("insert denied receipt — event guard is deferred"); - - // Insert the mapped success-transition event (event_kind = 1, enrolled). - // actor_kind = 1 requires a non-null actor_fingerprint and a matching - // receipt FK (satisfied by the denied receipt above, which shares the - // same (community_id, operation_id, request_fingerprint)). - sqlx::query( - "INSERT INTO authorization_events \ - (community_id, event_id, event_kind, outcome_code, reason_code, \ - actor_kind, actor_fingerprint, operation_id, request_fingerprint, \ - correlation_id, attempt_id, semantic_fingerprint, \ - occurred_at, canonical_envelope, envelope_digest) \ - VALUES ($1, $2, 1, 1, 4, 1, $3, $4, $5, $6, $7, NULL, \ - '2026-01-01T00:00:00Z', $8, $9)", - // event_kind 1 (enrolled) — the mapped success transition for enroll - ) - .bind(community_id) - .bind(event_neg) - .bind(vec![0xD4_u8; 32]) // actor_fingerprint - .bind(op_neg) - .bind(&fp_neg) - .bind(corr_neg) - .bind(attempt_neg) - .bind(vec![0xD5_u8; 64]) // canonical_envelope - .bind(vec![0xD6_u8; 32]) // envelope_digest - .execute(&mut *conn_neg) - .await - .expect("event INSERT must pass — deferred guard fires at COMMIT"); - - let contradiction_err = sqlx::query("COMMIT") - .execute(&mut *conn_neg) - .await - .expect_err( - "denied receipt + mapped success event must be rejected at COMMIT \ - — contradictory durable facts must not be permitted", - ); - assert_eq!( - contradiction_err - .as_database_error() - .and_then(|e| e.constraint()), - Some("authorization_denied_lifecycle_receipt_no_success_event"), - "expected authorization_denied_lifecycle_receipt_no_success_event constraint \ - rejection for denied receipt + success event, got: {contradiction_err}" - ); - } - - /// NIP-FI event-side trigger isolation: when a denied enroll receipt is already - /// committed (auto-commit via pool), a new independent transaction that inserts - /// only the mapped success-transition event must be rejected at COMMIT by - /// `authorization_event_receipt_cardinality` (the event-side deferred trigger). - /// - /// This isolates the `authorization_event_receipt_cardinality` trigger path. - /// In `denied_lifecycle_receipt_commits_without_audit_event`'s receipt-then-event - /// negative, the receipt-side trigger (`authorization_operation_receipt_event_cardinality`) - /// also fires. Here the committed receipt produces no deferred trigger, so rejection - /// can only come from the event-side trigger. Uses the mapped kind (kind 1 for - /// enroll). Wrong-kind event-side isolation is in - /// `denied_lifecycle_receipt_wrong_kind_event_side`. - /// - /// Mutation sensitivity: disabling the - /// `authorization_event_receipt_cardinality` trigger (DROP or ALTER TABLE - /// DISABLE TRIGGER) makes this negative green — the COMMIT succeeds when it - /// must not, so `expect_err` panics. - #[tokio::test] - #[ignore = "requires Postgres"] - async fn denied_lifecycle_receipt_event_side_trigger_isolated() { - let pool = connect_test_pool().await; - reset_public_schema(&pool).await; - MIGRATOR - .run_to(42, &pool) - .await - .expect("apply migrations 1-42"); - - let community_id = uuid::Uuid::new_v4(); - sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)") - .bind(community_id) - .bind(format!("evt-side-{}.example", community_id.simple())) - .execute(&pool) - .await - .expect("insert community"); - - // Seed event capacity before any event insert. - sqlx::query( - "INSERT INTO authorization_event_capacity \ - (community_id, max_events_per_domain, max_bytes_per_domain, max_envelope_bytes) \ - VALUES ($1, 1000, 16777216, 16384)", - ) - .bind(community_id) - .execute(&pool) - .await - .expect("insert event capacity"); - - // Commit a denied enroll receipt in auto-commit mode (no explicit BEGIN). - // This receipt produces no deferred trigger — the receipt-side deferred - // trigger only fires within the transaction that inserts the receipt row. - let op_id = uuid::Uuid::new_v4(); - let fp = vec![0xE1_u8; 32]; - sqlx::query( - "INSERT INTO authorization_operation_receipts \ - (community_id, operation_id, request_fingerprint, operation_kind, \ - actor_fingerprint, outcome_code, result_digest) \ - VALUES ($1, $2, $3, 1, $4, 2, $5)", - ) - .bind(community_id) - .bind(op_id) - .bind(&fp) - .bind(vec![0xE2_u8; 32]) - .bind(vec![0xE3_u8; 32]) - .execute(&pool) - .await - .expect("denied receipt must commit alone in auto-commit mode"); - - // Now open a NEW transaction and insert only the mapped success-transition - // event (event_kind = 1, enrolled). The receipt is already committed and - // its deferred trigger is no longer active. Rejection at COMMIT must come - // from authorization_event_receipt_cardinality (the event-side trigger). - let event_id = uuid::Uuid::new_v4(); - let corr_id = uuid::Uuid::new_v4(); - let attempt_id = uuid::Uuid::new_v4(); - - let mut conn = pool - .acquire() - .await - .expect("acquire connection for event-side test"); - sqlx::query("BEGIN") - .execute(&mut *conn) - .await - .expect("begin event-side transaction"); - - sqlx::query( - "INSERT INTO authorization_events \ - (community_id, event_id, event_kind, outcome_code, reason_code, \ - actor_kind, actor_fingerprint, operation_id, request_fingerprint, \ - correlation_id, attempt_id, semantic_fingerprint, \ - occurred_at, canonical_envelope, envelope_digest) \ - VALUES ($1, $2, 1, 1, 4, 1, $3, $4, $5, $6, $7, NULL, \ - '2026-01-01T00:00:00Z', $8, $9)", - ) - .bind(community_id) - .bind(event_id) - .bind(vec![0xE4_u8; 32]) // actor_fingerprint - .bind(op_id) - .bind(&fp) - .bind(corr_id) - .bind(attempt_id) - .bind(vec![0xE5_u8; 64]) // canonical_envelope - .bind(vec![0xE6_u8; 32]) // envelope_digest - .execute(&mut *conn) - .await - .expect("event INSERT must pass — event-side deferred guard fires at COMMIT"); - - let event_side_err = sqlx::query("COMMIT").execute(&mut *conn).await.expect_err( - "mapping a success-transition event to a committed denied receipt \ - must be rejected at COMMIT by the event-side trigger", - ); - assert_eq!( - event_side_err - .as_database_error() - .and_then(|e| e.constraint()), - Some("authorization_denied_lifecycle_receipt_no_success_event"), - "event-side trigger must reject with authorization_denied_lifecycle_receipt_no_success_event, \ - got: {event_side_err}" - ); - } - - /// NIP-FI applied lifecycle receipt: an applied core lifecycle enroll receipt - /// (outcome_code = 1) requires exactly one mapped success-transition event - /// (event_kind = 1, enrolled). This exercises the `outcome_code IN (1, 3)` - /// branch of `authorization_operation_receipt_event_guard_v1` at migration 42. - /// - /// Mutation sensitivity: - /// - Removing/bypassing the applied/no-op branch (replacing it with a blanket - /// RETURN NULL) makes the positive transaction commit without an event, leaving - /// the contract silently unenforced. The negative below requires the cardinality - /// constraint to fire when the event is absent. - /// - Removing the negative assertion: the absent-event case would commit when it - /// must not. - #[tokio::test] - #[ignore = "requires Postgres"] - async fn applied_lifecycle_receipt_requires_exactly_one_event() { - let pool = connect_test_pool().await; - reset_public_schema(&pool).await; - MIGRATOR - .run_to(42, &pool) - .await - .expect("apply migrations 1-42"); - - let community_id = uuid::Uuid::new_v4(); - sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)") - .bind(community_id) - .bind(format!( - "applied-lifecycle-{}.example", - community_id.simple() - )) - .execute(&pool) - .await - .expect("insert community"); - - // Enrollment policy (TOFU, mode 3). - let policy_revision: i64 = 1; - sqlx::query( - "INSERT INTO identity_enrollment_policies \ - (community_id, policy_revision, enrollment_mode, policy_digest, effective_at) \ - VALUES ($1, $2, 3, $3, '2026-01-01T00:00:00Z')", - ) - .bind(community_id) - .bind(policy_revision) - .bind(vec![0xF0_u8; 32]) - .execute(&pool) - .await - .expect("insert enrollment policy"); - - // Event capacity — required by authorization_event_capacity_before_insert_v1. - sqlx::query( - "INSERT INTO authorization_event_capacity \ - (community_id, max_events_per_domain, max_bytes_per_domain, max_envelope_bytes) \ - VALUES ($1, 1000, 16777216, 16384)", - ) - .bind(community_id) - .execute(&pool) - .await - .expect("insert event capacity"); - - // --- Positive: applied enroll commits with exactly one mapped event --- - // - // All cross-table FKs between identity_lifecycle_history, identity_bindings, - // authorization_operation_receipts, and authorization_events are - // DEFERRABLE INITIALLY DEFERRED — insert order within the transaction is - // flexible, but a pinned connection is required for BEGIN/COMMIT to share - // the same session. The receipt_history_cardinality trigger (migration 0041) - // fires at COMMIT and requires exactly one history row for applied enroll. - let op_id = uuid::Uuid::new_v4(); - let binding_id = uuid::Uuid::new_v4(); - let history_id = uuid::Uuid::new_v4(); - let fp = vec![0xF1_u8; 32]; - let event_id = uuid::Uuid::new_v4(); - let corr_id = uuid::Uuid::new_v4(); - let attempt_id = uuid::Uuid::new_v4(); - - let mut conn = pool - .acquire() - .await - .expect("acquire connection for positive case"); - sqlx::query("BEGIN") - .execute(&mut *conn) - .await - .expect("begin positive transaction"); - - // History first: the receipt_history_cardinality AFTER INSERT trigger - // on authorization_operation_receipts is DEFERRED and checks at COMMIT - // time, but inserting history before receipt is idiomatic. - // successor_binding_version = 1 because binding_version is an identity - // sequence starting at 1 per community; this is the first binding. - sqlx::query( - "INSERT INTO identity_lifecycle_history \ - (community_id, history_id, transition_kind, outcome_code, \ - successor_binding_id, successor_binding_version, \ - successor_lifecycle_revision, successor_state, \ - operation_id, request_fingerprint, transition_digest) \ - VALUES ($1, $2, 1, 1, $3, 1, 1, 1, $4, $5, $6)", - ) - .bind(community_id) - .bind(history_id) - .bind(binding_id) - .bind(op_id) - .bind(&fp) - .bind(vec![0xF2_u8; 32]) - .execute(&mut *conn) - .await - .expect("insert lifecycle history"); - - // Applied enroll receipt (operation_kind = 1, outcome_code = 1). - sqlx::query( - "INSERT INTO authorization_operation_receipts \ - (community_id, operation_id, request_fingerprint, operation_kind, \ - actor_fingerprint, outcome_code, result_digest) \ - VALUES ($1, $2, $3, 1, $4, 1, $5)", - ) - .bind(community_id) - .bind(op_id) - .bind(&fp) - .bind(vec![0xF3_u8; 32]) - .bind(vec![0xF4_u8; 32]) - .execute(&mut *conn) - .await - .expect("insert applied enroll receipt"); - - // Binding — birth_history_id FK is deferred; binding_version is generated. - sqlx::query( - "INSERT INTO identity_bindings \ - (community_id, binding_id, issuer, subject, \ - principal_fingerprint, event_author_pubkey, \ - binding_state, lifecycle_revision, binding_provenance, \ - policy_revision, enrollment_evidence_digest, \ - birth_history_id, creation_operation_id, \ - creation_request_fingerprint) \ - VALUES ($1, $2, 'https://issuer.example', 'sub-applied', \ - $3, $4, 1, 1, 1, $5, $6, $7, $8, $9)", - ) - .bind(community_id) - .bind(binding_id) - .bind(vec![0xF5_u8; 32]) // principal_fingerprint - .bind(vec![0xF6_u8; 32]) // event_author_pubkey - .bind(policy_revision) - .bind(vec![0xF7_u8; 32]) // enrollment_evidence_digest - .bind(history_id) - .bind(op_id) - .bind(&fp) - .execute(&mut *conn) - .await - .expect("insert identity binding"); - - // Mapped success-transition event (event_kind = 1, enrolled). - // actor_kind = 1 requires non-null actor_fingerprint and matching receipt FK. - sqlx::query( - "INSERT INTO authorization_events \ - (community_id, event_id, event_kind, outcome_code, reason_code, \ - actor_kind, actor_fingerprint, operation_id, request_fingerprint, \ - correlation_id, attempt_id, semantic_fingerprint, \ - occurred_at, canonical_envelope, envelope_digest) \ - VALUES ($1, $2, 1, 1, 4, 1, $3, $4, $5, $6, $7, NULL, \ - '2026-01-01T00:00:00Z', $8, $9)", - ) - .bind(community_id) - .bind(event_id) - .bind(vec![0xF8_u8; 32]) // actor_fingerprint - .bind(op_id) - .bind(&fp) - .bind(corr_id) - .bind(attempt_id) - .bind(vec![0xF9_u8; 64]) // canonical_envelope - .bind(vec![0xFA_u8; 32]) // envelope_digest - .execute(&mut *conn) - .await - .expect("insert mapped success-transition event"); - - sqlx::query("COMMIT").execute(&mut *conn).await.expect( - "applied enroll receipt + exactly one mapped event must commit — \ - authorization_operation_receipt_event_guard_v1 applied/no-op branch", - ); - - // Confirm exactly one event committed for this operation. - let event_count: i64 = sqlx::query_scalar( - "SELECT count(*) FROM authorization_events \ - WHERE community_id = $1 AND operation_id = $2", - ) - .bind(community_id) - .bind(op_id) - .fetch_one(&pool) - .await - .expect("count events for applied receipt"); - assert_eq!( - event_count, 1, - "exactly one audit event must be present for an applied enroll receipt" - ); - - // --- Negative: applied enroll receipt without a mapped event must reject --- - // - // A second applied enroll transaction that commits receipt + history + binding - // but no event must be rejected with authorization_operation_receipt_event_cardinality. - let op_neg = uuid::Uuid::new_v4(); - let binding_neg = uuid::Uuid::new_v4(); - let history_neg = uuid::Uuid::new_v4(); - let fp_neg = vec![0xFB_u8; 32]; - - let mut conn_neg = pool - .acquire() - .await - .expect("acquire connection for negative case"); - sqlx::query("BEGIN") - .execute(&mut *conn_neg) - .await - .expect("begin negative transaction"); - - sqlx::query( - "INSERT INTO identity_lifecycle_history \ - (community_id, history_id, transition_kind, outcome_code, \ - successor_binding_id, successor_binding_version, \ - successor_lifecycle_revision, successor_state, \ - operation_id, request_fingerprint, transition_digest) \ - VALUES ($1, $2, 1, 1, $3, 2, 1, 1, $4, $5, $6)", - // successor_binding_version = 2: second binding in this community - ) - .bind(community_id) - .bind(history_neg) - .bind(binding_neg) - .bind(op_neg) - .bind(&fp_neg) - .bind(vec![0xFC_u8; 32]) - .execute(&mut *conn_neg) - .await - .expect("insert negative lifecycle history"); - - sqlx::query( - "INSERT INTO authorization_operation_receipts \ - (community_id, operation_id, request_fingerprint, operation_kind, \ - actor_fingerprint, outcome_code, result_digest) \ - VALUES ($1, $2, $3, 1, $4, 1, $5)", - ) - .bind(community_id) - .bind(op_neg) - .bind(&fp_neg) - .bind(vec![0xFD_u8; 32]) - .bind(vec![0xFE_u8; 32]) - .execute(&mut *conn_neg) - .await - .expect("insert negative applied receipt"); - - sqlx::query( - "INSERT INTO identity_bindings \ - (community_id, binding_id, issuer, subject, \ - principal_fingerprint, event_author_pubkey, \ - binding_state, lifecycle_revision, binding_provenance, \ - policy_revision, enrollment_evidence_digest, \ - birth_history_id, creation_operation_id, \ - creation_request_fingerprint) \ - VALUES ($1, $2, 'https://issuer.example', 'sub-applied-neg', \ - $3, $4, 1, 1, 1, $5, $6, $7, $8, $9)", - ) - .bind(community_id) - .bind(binding_neg) - .bind(vec![0xE7_u8; 32]) // principal_fingerprint (distinct from positive) - .bind(vec![0xE8_u8; 32]) // event_author_pubkey (distinct from positive) - .bind(policy_revision) - .bind(vec![0xE9_u8; 32]) - .bind(history_neg) - .bind(op_neg) - .bind(&fp_neg) - .execute(&mut *conn_neg) - .await - .expect("insert negative binding — no event inserted"); - - // Commit without the mapped event — guard must reject. - let absent_event_err = sqlx::query("COMMIT") - .execute(&mut *conn_neg) - .await - .expect_err( - "applied enroll receipt without a mapped success-transition event \ - must be rejected at COMMIT", - ); - assert_eq!( - absent_event_err - .as_database_error() - .and_then(|e| e.constraint()), - Some("authorization_operation_receipt_event_cardinality"), - "expected authorization_operation_receipt_event_cardinality rejection \ - for applied receipt without event, got: {absent_event_err}" - ); - } - - /// NIP-FI cross-kind denied lifecycle: a wrong success-transition kind paired - /// with a denied lifecycle receipt must be rejected through the receipt-side - /// deferred trigger. Uses a denied enroll receipt (operation_kind = 1, mapped - /// kind = 1) with a kind-6 (retired) event — a different success-transition - /// kind that is equally forbidden by the class-based guard (kinds 1, 2, 3, 6). - /// - /// Both the receipt and the wrong-kind event are inserted in the same - /// transaction, so the receipt-side deferred trigger - /// (`authorization_operation_receipt_event_cardinality`) fires at COMMIT. - /// - /// Mutation sensitivity: narrowing the denied filter back to - /// `event_kind = expected_event_kind` (the mapped kind, 1) removes kind 6 - /// from the forbidden set, causing this negative to turn green — COMMIT - /// succeeds when it must not, failing `expect_err`. - #[tokio::test] - #[ignore = "requires Postgres"] - async fn denied_lifecycle_receipt_wrong_kind_receipt_side() { - let pool = connect_test_pool().await; - reset_public_schema(&pool).await; - MIGRATOR - .run_to(42, &pool) - .await - .expect("apply migrations 1-42"); - - let community_id = uuid::Uuid::new_v4(); - sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)") - .bind(community_id) - .bind(format!("wrong-kind-rcpt-{}.example", community_id.simple())) - .execute(&pool) - .await - .expect("insert community"); - - sqlx::query( - "INSERT INTO authorization_event_capacity \ - (community_id, max_events_per_domain, max_bytes_per_domain, max_envelope_bytes) \ - VALUES ($1, 1000, 16777216, 16384)", - ) - .bind(community_id) - .execute(&pool) - .await - .expect("insert event capacity"); - - let op_id = uuid::Uuid::new_v4(); - let fp = vec![0xA0_u8; 32]; - let event_id = uuid::Uuid::new_v4(); - let corr_id = uuid::Uuid::new_v4(); - let attempt_id = uuid::Uuid::new_v4(); - - let mut conn = pool - .acquire() - .await - .expect("acquire connection for wrong-kind receipt-side test"); - sqlx::query("BEGIN") - .execute(&mut *conn) - .await - .expect("begin"); - - // Denied enroll receipt (operation_kind = 1, outcome_code = 2). - // No history row needed: outcome_code = 2 expects zero history rows. - sqlx::query( - "INSERT INTO authorization_operation_receipts \ - (community_id, operation_id, request_fingerprint, operation_kind, \ - actor_fingerprint, outcome_code, result_digest) \ - VALUES ($1, $2, $3, 1, $4, 2, $5)", - ) - .bind(community_id) - .bind(op_id) - .bind(&fp) - .bind(vec![0xA1_u8; 32]) - .bind(vec![0xA2_u8; 32]) - .execute(&mut *conn) - .await - .expect("insert denied enroll receipt — deferred guard"); - - // Wrong success-transition kind: event_kind = 6 (retired), not the mapped - // kind 1 (enrolled). Both are in the forbidden class (1, 2, 3, 6). - sqlx::query( - "INSERT INTO authorization_events \ - (community_id, event_id, event_kind, outcome_code, reason_code, \ - actor_kind, actor_fingerprint, operation_id, request_fingerprint, \ - correlation_id, attempt_id, semantic_fingerprint, \ - occurred_at, canonical_envelope, envelope_digest) \ - VALUES ($1, $2, 6, 1, 4, 1, $3, $4, $5, $6, $7, NULL, \ - '2026-01-01T00:00:00Z', $8, $9)", - // event_kind 6 (retired) — wrong kind for a denied enroll receipt - ) - .bind(community_id) - .bind(event_id) - .bind(vec![0xA3_u8; 32]) // actor_fingerprint - .bind(op_id) - .bind(&fp) - .bind(corr_id) - .bind(attempt_id) - .bind(vec![0xA4_u8; 64]) // canonical_envelope - .bind(vec![0xA5_u8; 32]) // envelope_digest - .execute(&mut *conn) - .await - .expect("event INSERT must pass — deferred guard fires at COMMIT"); - - let wrong_kind_err = sqlx::query("COMMIT").execute(&mut *conn).await.expect_err( - "denied receipt + wrong success-transition kind (6) must be rejected at COMMIT \ - — class-based guard forbids all of kinds 1, 2, 3, 6", - ); - assert_eq!( - wrong_kind_err - .as_database_error() - .and_then(|e| e.constraint()), - Some("authorization_denied_lifecycle_receipt_no_success_event"), - "expected authorization_denied_lifecycle_receipt_no_success_event for \ - denied receipt + wrong kind (6), got: {wrong_kind_err}" - ); - } - - /// NIP-FI cross-kind denied lifecycle event-side: after a denied enroll - /// receipt is committed alone (auto-commit), a new transaction that inserts - /// only a wrong success-transition kind (kind 6, retired) must be rejected at - /// COMMIT by `authorization_event_receipt_cardinality` (event-side trigger). - /// - /// This isolates the event-side trigger path for the cross-kind case. - /// The committed receipt produces no active deferred trigger, so rejection - /// can only come from the event-side trigger. - /// - /// Mutation sensitivity: narrowing the denied filter to - /// `event_kind = expected_event_kind` (kind 1) removes kind 6 from the - /// forbidden set, making this negative green — COMMIT succeeds when it must - /// not, failing `expect_err`. - #[tokio::test] - #[ignore = "requires Postgres"] - async fn denied_lifecycle_receipt_wrong_kind_event_side() { - let pool = connect_test_pool().await; - reset_public_schema(&pool).await; - MIGRATOR - .run_to(42, &pool) - .await - .expect("apply migrations 1-42"); - - let community_id = uuid::Uuid::new_v4(); - sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)") - .bind(community_id) - .bind(format!("wrong-kind-evt-{}.example", community_id.simple())) - .execute(&pool) - .await - .expect("insert community"); - - sqlx::query( - "INSERT INTO authorization_event_capacity \ - (community_id, max_events_per_domain, max_bytes_per_domain, max_envelope_bytes) \ - VALUES ($1, 1000, 16777216, 16384)", - ) - .bind(community_id) - .execute(&pool) - .await - .expect("insert event capacity"); - - // Commit a denied enroll receipt in auto-commit mode. No deferred trigger - // is active after this commit; the receipt-side trigger fires only within - // the transaction that inserts the receipt. - let op_id = uuid::Uuid::new_v4(); - let fp = vec![0xB0_u8; 32]; - sqlx::query( - "INSERT INTO authorization_operation_receipts \ - (community_id, operation_id, request_fingerprint, operation_kind, \ - actor_fingerprint, outcome_code, result_digest) \ - VALUES ($1, $2, $3, 1, $4, 2, $5)", - ) - .bind(community_id) - .bind(op_id) - .bind(&fp) - .bind(vec![0xB1_u8; 32]) - .bind(vec![0xB2_u8; 32]) - .execute(&pool) - .await - .expect("denied receipt must commit alone in auto-commit mode"); - - // New transaction: insert only a kind-6 (retired) event for the same - // operation. The event-side trigger is the only active deferred trigger. - let event_id = uuid::Uuid::new_v4(); - let corr_id = uuid::Uuid::new_v4(); - let attempt_id = uuid::Uuid::new_v4(); - - let mut conn = pool - .acquire() - .await - .expect("acquire connection for wrong-kind event-side test"); - sqlx::query("BEGIN") - .execute(&mut *conn) - .await - .expect("begin event-side wrong-kind transaction"); - - sqlx::query( - "INSERT INTO authorization_events \ - (community_id, event_id, event_kind, outcome_code, reason_code, \ - actor_kind, actor_fingerprint, operation_id, request_fingerprint, \ - correlation_id, attempt_id, semantic_fingerprint, \ - occurred_at, canonical_envelope, envelope_digest) \ - VALUES ($1, $2, 6, 1, 4, 1, $3, $4, $5, $6, $7, NULL, \ - '2026-01-01T00:00:00Z', $8, $9)", - // event_kind 6 (retired) — wrong kind for the denied enroll receipt - ) - .bind(community_id) - .bind(event_id) - .bind(vec![0xB3_u8; 32]) // actor_fingerprint - .bind(op_id) - .bind(&fp) - .bind(corr_id) - .bind(attempt_id) - .bind(vec![0xB4_u8; 64]) // canonical_envelope - .bind(vec![0xB5_u8; 32]) // envelope_digest - .execute(&mut *conn) - .await - .expect("event INSERT must pass — event-side deferred guard fires at COMMIT"); - - let wrong_kind_evt_err = sqlx::query("COMMIT").execute(&mut *conn).await.expect_err( - "kind-6 event paired with a committed denied enroll receipt must be \ - rejected at COMMIT by the event-side trigger", - ); - assert_eq!( - wrong_kind_evt_err - .as_database_error() - .and_then(|e| e.constraint()), - Some("authorization_denied_lifecycle_receipt_no_success_event"), - "event-side trigger must reject with authorization_denied_lifecycle_receipt_no_success_event \ - for wrong kind (6) against denied receipt, got: {wrong_kind_evt_err}" - ); + .expect("deletion catalog validates after migration 0044"); } } diff --git a/docs/nips/NIP-FI.md b/docs/nips/NIP-FI.md index 845a150a4e..08fa5762ee 100644 --- a/docs/nips/NIP-FI.md +++ b/docs/nips/NIP-FI.md @@ -155,11 +155,8 @@ single-issuer deployment is a registry of length one. [FI-TRACE-CROSS-DOMAIN-CO The existing `FederatedAssertionVerifier` and `ProductionJwksSource` (merged in PR 3 / `70895b355`) implement the verification procedure described -here. The `require_attested_key` flag in `IssuerPolicy` is the per-issuer -enforcement primitive for the unconditional `nostr_pubkey` requirement in this -section; conformance to NIP-FI v2 requires startup validation that forces this -flag true for every configured issuer. That integration is a follow-on code -change outside this PR. +here. The `nostr_pubkey` claim is unconditionally required — absence rejects +regardless of issuer policy (NIP-FI v2, PR #7221). ### JWKS snapshot @@ -402,8 +399,7 @@ Any failure at any step is fail-closed: no side effects occur and the relay returns the appropriate error. This verifier and the disconnect API endpoint are follow-on code changes -outside this PR, in the same way that the `require_attested_key` enforcement -integration is. +outside this PR. ### Request diff --git a/migrations/0044_drop_nip_fi_ledger.sql b/migrations/0044_drop_nip_fi_ledger.sql new file mode 100644 index 0000000000..34dead49a7 --- /dev/null +++ b/migrations/0044_drop_nip_fi_ledger.sql @@ -0,0 +1,69 @@ +-- Remove the Phase-A NIP-FI relay-side authority ledger (migrations 0041 and +-- 0042). Will+Tyler resolved 2026-09-01: OSS Buzz is stateless for identity +-- ("Buzz speaks Nostr, nothing else"). The merged NIP-FI spec v2 (PR #7214, +-- squash d4420eb47) requires the nostr_pubkey claim + NIP-42 proof +-- unconditionally; the durable ledger tables are dead code. +-- +-- CASCADE handles the circular deferred FK between identity_bindings and +-- identity_lifecycle_history, and dispenses with strict drop ordering. + +-- ── 0042 tables ───────────────────────────────────────────────────────────── +DROP TABLE authorization_operation_version_deltas CASCADE; +DROP TABLE authorization_operation_version_delta_manifests CASCADE; +DROP TABLE authorization_authentication_denial_attempts CASCADE; +DROP TABLE authorization_admission_results CASCADE; +DROP TABLE authorization_events CASCADE; +DROP TABLE protected_object_authority CASCADE; +DROP TABLE authorization_authority_epochs CASCADE; +DROP TABLE authorization_invalidation_floors CASCADE; +DROP TABLE authorization_invalidation_domains CASCADE; +DROP TABLE authorization_event_capacity CASCADE; + +-- ── 0041 tables ───────────────────────────────────────────────────────────── +-- Circular deferred FK: identity_bindings ↔ identity_lifecycle_history. +-- DROP TABLE with CASCADE resolves it without a two-step ALTER/DROP. +DROP TABLE identity_lifecycle_selectors CASCADE; +DROP TABLE identity_lifecycle_history CASCADE; +DROP TABLE identity_bindings CASCADE; +DROP TABLE identity_enrollment_policies CASCADE; +DROP TABLE authorization_operation_receipts CASCADE; + +-- ── Shared functions (0041 introduced, 0042 widened) ──────────────────────── +-- Triggers were dropped with their tables above; drop functions separately. +DROP FUNCTION identity_enrollment_policy_revision_guard_v1; +DROP FUNCTION nip_fi_reject_row_mutation_v1; +DROP FUNCTION nip_fi_reject_truncate_v1; +DROP FUNCTION identity_lifecycle_lock_coordinates_v1; +DROP FUNCTION identity_bindings_insert_guard_v1; +DROP FUNCTION identity_bindings_transition_guard_v1; +DROP FUNCTION identity_lifecycle_history_insert_guard_v1; +DROP FUNCTION identity_binding_history_semantics_guard_v1; +DROP FUNCTION identity_binding_birth_eligibility_guard_v1; +DROP FUNCTION authorization_operation_receipt_history_guard_v1; +DROP FUNCTION identity_lifecycle_selector_insert_guard_v1; +DROP FUNCTION identity_lifecycle_selector_history_guard_v1; +DROP FUNCTION identity_lifecycle_transition_integrity_guard_v1; +DROP FUNCTION authorization_event_capacity_before_insert_v1; +DROP FUNCTION authorization_invalidation_domain_guard_v1; +DROP FUNCTION authorization_invalidation_floor_guard_v1; +DROP FUNCTION authorization_authority_epoch_guard_v1; +DROP FUNCTION authorization_event_capacity_guard_v1; +DROP FUNCTION protected_object_authority_guard_v1; +DROP FUNCTION authorization_denial_attempt_guard_v1; +DROP FUNCTION authorization_operation_version_delta_cardinality_guard_v1; +DROP FUNCTION authorization_admission_result_guard_v1; +DROP FUNCTION authorization_operation_receipt_event_guard_v1; + +-- ── Restore community_write_fence_excluded_table to its pre-0041 body ─────── +-- 0041 and 0042 each widened this function via CREATE OR REPLACE to exempt +-- the NIP-FI ledger relations from the community write fence and deletion +-- catalog. With those tables gone, revert to migration 0030's body. +CREATE OR REPLACE FUNCTION community_write_fence_excluded_table(target NAME) RETURNS BOOLEAN +LANGUAGE SQL IMMUTABLE STRICT PARALLEL SAFE AS $$ + SELECT target::TEXT = ANY (ARRAY[ + 'community_deletion_requests', 'community_deletion_approvals', + 'community_deletion_checkpoints', 'community_serving_write_leases', + 'community_deletion_executor_heartbeats', 'product_feedback', + 'rate_limit_violations' + ]::TEXT[]) +$$; diff --git a/schema/schema.sql b/schema/schema.sql index 7d18d825a8..0950812562 100644 --- a/schema/schema.sql +++ b/schema/schema.sql @@ -1488,16 +1488,7 @@ LANGUAGE SQL IMMUTABLE STRICT PARALLEL SAFE AS $$ 'community_deletion_requests', 'community_deletion_approvals', 'community_deletion_checkpoints', 'community_serving_write_leases', 'community_deletion_executor_heartbeats', 'product_feedback', - 'rate_limit_violations', - 'authorization_operation_receipts', 'identity_enrollment_policies', - 'identity_bindings', 'identity_lifecycle_history', - 'identity_lifecycle_selectors', - 'authorization_invalidation_domains', 'authorization_invalidation_floors', - 'authorization_authority_epochs', 'protected_object_authority', - 'authorization_event_capacity', 'authorization_events', - 'authorization_authentication_denial_attempts', - 'authorization_operation_version_delta_manifests', - 'authorization_operation_version_deltas', 'authorization_admission_results' + 'rate_limit_violations' ]::TEXT[]) $$; @@ -1902,1888 +1893,3 @@ CREATE INDEX idx_relay_operator_audit_target INSERT INTO _operator_global_tables (table_name, reason) VALUES ('relay_operator_audit', 'deployment-global append-only roster mutation audit trail; no community_id intentionally'); - --- ============================================================================ --- NIP-FI core identity + base-lifecycle foundation (mirror of migration 0041). --- The community_write_fence_excluded_table definition above already folds in --- the NIP-FI ledger relations; the per-migration CREATE OR REPLACE bodies are --- intentionally omitted here (desired state keeps one consolidated definition). --- ============================================================================ - --- The sole idempotency/result root shared by identity base lifecycle, --- protected operations, and invalidation. Pre-authentication denials never --- write this table. ExactReplay and IntentConflict are read-time observations, --- not persisted outcomes. -CREATE TABLE authorization_operation_receipts ( - community_id UUID NOT NULL REFERENCES communities(id), - operation_id UUID NOT NULL, - request_fingerprint BYTEA NOT NULL CHECK (octet_length(request_fingerprint) = 32), - -- Core operation kinds: 1 enroll, 3 retire, 5 revoke, 6 rotate, - -- 11 protected mutation, 12 invalidation. Extended lifecycle kinds - -- (2 provision, 4 disable, 7 recover, 8 enable, 9 admission loss) and - -- 10 operator are introduced by their owning later migrations. - operation_kind SMALLINT NOT NULL CHECK ( - operation_kind IN (1, 3, 5, 6, 11, 12) - ), - actor_fingerprint BYTEA NOT NULL CHECK (octet_length(actor_fingerprint) = 32), - -- 1 applied, 2 denied, 3 no-op. - outcome_code SMALLINT NOT NULL CHECK (outcome_code IN (1, 2, 3)), - result_digest BYTEA NOT NULL CHECK (octet_length(result_digest) = 32), - recorded_at TIMESTAMPTZ NOT NULL DEFAULT transaction_timestamp(), - PRIMARY KEY (community_id, operation_id), - UNIQUE (community_id, operation_id, request_fingerprint), - UNIQUE ( - community_id, - operation_id, - request_fingerprint, - operation_kind, - outcome_code - ), - CHECK (operation_id <> '00000000-0000-0000-0000-000000000000'::uuid) -); - --- Immutable monotonic local policy revisions. Enrollment modes are the closed --- provider-free V1 set: 1 attested-key, 2 provisioned, 3 risk-labelled TOFU. -CREATE TABLE identity_enrollment_policies ( - community_id UUID NOT NULL REFERENCES communities(id), - policy_revision BIGINT NOT NULL CHECK (policy_revision > 0), - enrollment_mode SMALLINT NOT NULL CHECK (enrollment_mode IN (1, 2, 3)), - policy_digest BYTEA NOT NULL CHECK (octet_length(policy_digest) = 32), - effective_at TIMESTAMPTZ NOT NULL, - -- Optional local binding-policy expiry. Federated token `exp` MUST NOT be - -- copied here: token lifetime bounds an authorization lease, not this - -- durable binding generation. - expires_at TIMESTAMPTZ, - recorded_at TIMESTAMPTZ NOT NULL DEFAULT transaction_timestamp(), - PRIMARY KEY (community_id, policy_revision), - CHECK (expires_at IS NULL OR effective_at < expires_at) -); - --- One row is one immutable binding generation. binding_version is allocated --- from one non-cycling PostgreSQL identity sequence and is never changed or --- reused. Explicit lifecycle may only retire the generation; X/Y denial --- semantics live in immutable selector facts below, not alternate row states. -CREATE TABLE identity_bindings ( - community_id UUID NOT NULL REFERENCES communities(id), - binding_id UUID NOT NULL, - binding_version BIGINT GENERATED ALWAYS AS IDENTITY ( - START WITH 1 INCREMENT BY 1 NO MINVALUE NO MAXVALUE CACHE 1 NO CYCLE - ), - issuer TEXT COLLATE "C" NOT NULL CHECK (octet_length(issuer) BETWEEN 1 AND 2048), - subject TEXT COLLATE "C" NOT NULL CHECK (octet_length(subject) BETWEEN 1 AND 2048), - principal_fingerprint BYTEA NOT NULL CHECK (octet_length(principal_fingerprint) = 32), - event_author_pubkey BYTEA NOT NULL CHECK (octet_length(event_author_pubkey) = 32), - -- 1 active, 2 retired. - binding_state SMALLINT NOT NULL CHECK (binding_state IN (1, 2)), - lifecycle_revision BIGINT NOT NULL CHECK (lifecycle_revision IN (1, 2)), - -- 1 attested-key, 2 provisioned, 3 risk-labelled TOFU. - binding_provenance SMALLINT NOT NULL CHECK (binding_provenance IN (1, 2, 3)), - policy_revision BIGINT NOT NULL CHECK (policy_revision > 0), - -- Canonical evidence for the selected provenance. This is an assertion - -- digest for attested/TOFU admission and a provisioning receipt digest for - -- separately provisioned admission; it never stores credential bytes. - enrollment_evidence_digest BYTEA NOT NULL CHECK ( - octet_length(enrollment_evidence_digest) = 32 - ), - expires_at TIMESTAMPTZ, - birth_history_id UUID NOT NULL, - creation_operation_id UUID NOT NULL, - creation_request_fingerprint BYTEA NOT NULL CHECK ( - octet_length(creation_request_fingerprint) = 32 - ), - retirement_history_id UUID, - created_at TIMESTAMPTZ NOT NULL DEFAULT transaction_timestamp(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT transaction_timestamp(), - PRIMARY KEY (community_id, binding_id), - UNIQUE (community_id, binding_version), - UNIQUE (community_id, binding_id, binding_version), - FOREIGN KEY (community_id, policy_revision) - REFERENCES identity_enrollment_policies - (community_id, policy_revision), - CHECK (binding_version > 0), - CHECK (binding_id <> '00000000-0000-0000-0000-000000000000'::uuid), - CHECK (birth_history_id <> '00000000-0000-0000-0000-000000000000'::uuid), - CHECK (creation_operation_id <> '00000000-0000-0000-0000-000000000000'::uuid), - CHECK (expires_at IS NULL OR created_at < expires_at), - CHECK ( - (binding_state = 1 AND lifecycle_revision = 1 AND retirement_history_id IS NULL) - OR (binding_state = 2 AND lifecycle_revision = 2 AND retirement_history_id IS NOT NULL) - ) -); - --- State 1 is Active. Expiry is evaluated with authoritative PostgreSQL time --- at read/finalization and is exclusive; it cannot appear in an index predicate. -CREATE UNIQUE INDEX identity_bindings_active_principal - ON identity_bindings (community_id, issuer, subject) - WHERE binding_state = 1; -CREATE INDEX identity_bindings_principal_fingerprint_lookup - ON identity_bindings (community_id, principal_fingerprint) - WHERE binding_state = 1; -CREATE UNIQUE INDEX identity_bindings_active_event_author - ON identity_bindings (community_id, event_author_pubkey) - WHERE binding_state = 1; -CREATE INDEX identity_bindings_current_lookup - ON identity_bindings (community_id, event_author_pubkey, binding_state, expires_at); - --- The one canonical immutable lifecycle transition row for a successful or --- no-op lifecycle operation. A transition can name an old generation, a new --- successor generation, both (Rotate), or neither (a semantic no-op). It is not --- a second result/effect engine: the shared receipt remains the sole persisted --- operation outcome. Core transition kinds only: 1 enroll, 3 retire, 5 revoke, --- 6 rotate. -CREATE TABLE identity_lifecycle_history ( - community_id UUID NOT NULL REFERENCES communities(id), - history_id UUID NOT NULL, - transition_kind SMALLINT NOT NULL CHECK ( - transition_kind IN (1, 3, 5, 6) - ), - -- Matches the shared receipt: 1 applied, 3 no-op. - outcome_code SMALLINT NOT NULL CHECK (outcome_code IN (1, 3)), - old_binding_id UUID, - old_binding_version BIGINT CHECK (old_binding_version IS NULL OR old_binding_version > 0), - old_prior_lifecycle_revision BIGINT CHECK ( - old_prior_lifecycle_revision IS NULL OR old_prior_lifecycle_revision IN (1, 2) - ), - old_prior_state SMALLINT CHECK (old_prior_state IS NULL OR old_prior_state IN (1, 2)), - old_resulting_lifecycle_revision BIGINT CHECK ( - old_resulting_lifecycle_revision IS NULL OR old_resulting_lifecycle_revision IN (1, 2) - ), - old_resulting_state SMALLINT CHECK ( - old_resulting_state IS NULL OR old_resulting_state IN (1, 2) - ), - successor_binding_id UUID, - successor_binding_version BIGINT CHECK ( - successor_binding_version IS NULL OR successor_binding_version > 0 - ), - successor_lifecycle_revision BIGINT CHECK ( - successor_lifecycle_revision IS NULL OR successor_lifecycle_revision = 1 - ), - successor_state SMALLINT CHECK (successor_state IS NULL OR successor_state = 1), - operation_id UUID NOT NULL, - request_fingerprint BYTEA NOT NULL CHECK (octet_length(request_fingerprint) = 32), - transition_digest BYTEA NOT NULL CHECK (octet_length(transition_digest) = 32), - recorded_at TIMESTAMPTZ NOT NULL DEFAULT transaction_timestamp(), - PRIMARY KEY (community_id, history_id), - UNIQUE (community_id, operation_id), - UNIQUE (community_id, history_id, operation_id, request_fingerprint), - UNIQUE ( - community_id, - history_id, - successor_binding_id, - successor_binding_version, - operation_id, - request_fingerprint - ), - UNIQUE ( - community_id, - history_id, - old_binding_id, - old_binding_version, - old_resulting_lifecycle_revision, - old_resulting_state - ), - FOREIGN KEY ( - community_id, - operation_id, - request_fingerprint, - transition_kind, - outcome_code - ) REFERENCES authorization_operation_receipts ( - community_id, - operation_id, - request_fingerprint, - operation_kind, - outcome_code - ) DEFERRABLE INITIALLY DEFERRED, - FOREIGN KEY (community_id, old_binding_id, old_binding_version) - REFERENCES identity_bindings (community_id, binding_id, binding_version) - DEFERRABLE INITIALLY DEFERRED, - FOREIGN KEY (community_id, successor_binding_id, successor_binding_version) - REFERENCES identity_bindings (community_id, binding_id, binding_version) - DEFERRABLE INITIALLY DEFERRED, - CHECK (history_id <> '00000000-0000-0000-0000-000000000000'::uuid), - CHECK (operation_id <> '00000000-0000-0000-0000-000000000000'::uuid), - CHECK ( - (old_binding_id IS NULL - AND old_binding_version IS NULL - AND old_prior_lifecycle_revision IS NULL - AND old_prior_state IS NULL - AND old_resulting_lifecycle_revision IS NULL - AND old_resulting_state IS NULL) - OR (old_binding_id IS NOT NULL - AND old_binding_version IS NOT NULL - AND old_prior_lifecycle_revision IS NOT NULL - AND old_prior_state IS NOT NULL - AND old_resulting_lifecycle_revision IS NOT NULL - AND old_resulting_state IS NOT NULL) - ), - CHECK ( - (successor_binding_id IS NULL - AND successor_binding_version IS NULL - AND successor_lifecycle_revision IS NULL - AND successor_state IS NULL) - OR (successor_binding_id IS NOT NULL - AND successor_binding_version IS NOT NULL - AND successor_lifecycle_revision = 1 - AND successor_state = 1) - ), - CHECK ( - old_binding_id IS NULL - OR successor_binding_id IS NULL - OR old_binding_id <> successor_binding_id - ), - CHECK ( - old_binding_version IS NULL - OR successor_binding_version IS NULL - OR old_binding_version <> successor_binding_version - ), - -- Core lifecycle only ever moves Active/r1 to Retired/r2 for a named old - -- generation. Extended re-enablement (recover/enable from Retired/r2) is a - -- later migration's concern. - CHECK ( - old_binding_id IS NULL - OR (old_prior_lifecycle_revision = 1 - AND old_prior_state = 1 - AND old_resulting_lifecycle_revision = 2 - AND old_resulting_state = 2) - ), - CHECK ( - (outcome_code = 3 - AND old_binding_id IS NULL - AND successor_binding_id IS NULL) - OR (outcome_code = 1 AND ( - (transition_kind = 1 - AND old_binding_id IS NULL - AND successor_binding_id IS NOT NULL) - OR (transition_kind = 3 - AND old_binding_id IS NOT NULL - AND successor_binding_id IS NULL) - OR (transition_kind = 5 - AND successor_binding_id IS NULL) - OR (transition_kind = 6 - AND old_binding_id IS NOT NULL - AND successor_binding_id IS NOT NULL) - )) - ) -); - -CREATE INDEX identity_lifecycle_history_old_binding - ON identity_lifecycle_history (community_id, old_binding_id, old_binding_version, recorded_at); -CREATE INDEX identity_lifecycle_history_successor_binding - ON identity_lifecycle_history ( - community_id, - successor_binding_id, - successor_binding_version, - recorded_at - ); - --- Circular birth/transition ordering is deliberate and fully deferred. Every --- generation must commit with its exact birth transition, and a retired row --- must commit with the exact transition that changed Active/r1 to Retired/r2. -ALTER TABLE identity_bindings - ADD CONSTRAINT identity_bindings_exact_birth_history_fk - FOREIGN KEY ( - community_id, - birth_history_id, - binding_id, - binding_version, - creation_operation_id, - creation_request_fingerprint - ) REFERENCES identity_lifecycle_history ( - community_id, - history_id, - successor_binding_id, - successor_binding_version, - operation_id, - request_fingerprint - ) DEFERRABLE INITIALLY DEFERRED; - -ALTER TABLE identity_bindings - ADD CONSTRAINT identity_bindings_exact_retirement_history_fk - FOREIGN KEY ( - community_id, - retirement_history_id, - binding_id, - binding_version, - lifecycle_revision, - binding_state - ) REFERENCES identity_lifecycle_history ( - community_id, - history_id, - old_binding_id, - old_binding_version, - old_resulting_lifecycle_revision, - old_resulting_state - ) DEFERRABLE INITIALLY DEFERRED; - --- One immutable closed-scope fact table. Core selector kinds only: --- 1 retired pair (P), 3 revoked key (Y). Both are permanent. The extended --- disabled-identity (X) and pending-replacement (Q) selectors, and their --- one-shot consumption, are introduced by the FI-LIFECYCLE migration. -CREATE TABLE identity_lifecycle_selectors ( - community_id UUID NOT NULL REFERENCES communities(id), - selector_id UUID NOT NULL, - selector_kind SMALLINT NOT NULL CHECK (selector_kind IN (1, 3)), - selector_fingerprint BYTEA NOT NULL CHECK (octet_length(selector_fingerprint) = 32), - fact_generation BIGINT NOT NULL CHECK (fact_generation > 0), - principal_fingerprint BYTEA CHECK ( - principal_fingerprint IS NULL OR octet_length(principal_fingerprint) = 32 - ), - event_author_pubkey BYTEA CHECK ( - event_author_pubkey IS NULL OR octet_length(event_author_pubkey) = 32 - ), - binding_id UUID, - binding_version BIGINT CHECK (binding_version IS NULL OR binding_version > 0), - asserted_history_id UUID NOT NULL, - selected_by_operation_id UUID NOT NULL, - selected_by_request_fingerprint BYTEA NOT NULL CHECK ( - octet_length(selected_by_request_fingerprint) = 32 - ), - selected_at TIMESTAMPTZ NOT NULL DEFAULT transaction_timestamp(), - PRIMARY KEY (community_id, selector_id), - UNIQUE (community_id, selector_id, selector_kind), - UNIQUE (community_id, selector_kind, selector_fingerprint, fact_generation), - FOREIGN KEY ( - community_id, - asserted_history_id, - selected_by_operation_id, - selected_by_request_fingerprint - ) REFERENCES identity_lifecycle_history ( - community_id, - history_id, - operation_id, - request_fingerprint - ) DEFERRABLE INITIALLY DEFERRED, - FOREIGN KEY ( - community_id, - selected_by_operation_id, - selected_by_request_fingerprint - ) REFERENCES authorization_operation_receipts ( - community_id, - operation_id, - request_fingerprint - ) DEFERRABLE INITIALLY DEFERRED, - FOREIGN KEY (community_id, binding_id, binding_version) - REFERENCES identity_bindings (community_id, binding_id, binding_version) - DEFERRABLE INITIALLY DEFERRED, - CHECK (selector_id <> '00000000-0000-0000-0000-000000000000'::uuid), - CHECK ( - (selector_kind = 1 - AND fact_generation = 1 - AND principal_fingerprint IS NOT NULL - AND event_author_pubkey IS NOT NULL - AND binding_id IS NOT NULL - AND binding_version IS NOT NULL) - OR (selector_kind = 3 - AND fact_generation = 1 - AND principal_fingerprint IS NULL - AND event_author_pubkey IS NOT NULL - AND binding_id IS NULL - AND binding_version IS NULL) - ) -); - -CREATE UNIQUE INDEX identity_lifecycle_selectors_permanent_pair - ON identity_lifecycle_selectors (community_id, binding_id, binding_version) - WHERE selector_kind = 1; -CREATE UNIQUE INDEX identity_lifecycle_selectors_permanent_principal_key - ON identity_lifecycle_selectors ( - community_id, - principal_fingerprint, - event_author_pubkey - ) WHERE selector_kind = 1; -CREATE UNIQUE INDEX identity_lifecycle_selectors_permanent_key - ON identity_lifecycle_selectors (community_id, event_author_pubkey) - WHERE selector_kind = 3; -CREATE INDEX identity_lifecycle_selectors_principal_lookup - ON identity_lifecycle_selectors - (community_id, selector_kind, principal_fingerprint, fact_generation); -CREATE INDEX identity_lifecycle_selectors_key_lookup - ON identity_lifecycle_selectors - (community_id, selector_kind, event_author_pubkey, fact_generation); -CREATE INDEX identity_lifecycle_selectors_binding_lookup - ON identity_lifecycle_selectors - (community_id, selector_kind, binding_id, binding_version, fact_generation); -CREATE INDEX identity_lifecycle_selectors_asserted_history - ON identity_lifecycle_selectors - (community_id, asserted_history_id, selector_kind); - --- Serializes policy-revision inserts per community: each new revision must --- strictly exceed the current maximum (FI-INV-06 — stable assertion policy --- anchor; a backfilled or replayed revision is incoherent). The per-community --- advisory lock prevents two concurrent writers from both passing a plain --- SELECT MAX() check and committing conflicting revisions. -CREATE FUNCTION identity_enrollment_policy_revision_guard_v1() RETURNS TRIGGER AS $$ -DECLARE - lock_key BIGINT; - max_revision BIGINT; -BEGIN - -- Acquire a per-community exclusive transaction-scoped advisory lock so - -- that concurrent insertions serialize here. The key is a stable hash of - -- the namespace string and the community_id bytes. - lock_key := hashtextextended( - 'buzz:enrollment-policy-revision:v1:' || NEW.community_id::text, - 0 - ); - PERFORM pg_advisory_xact_lock(lock_key); - - SELECT MAX(policy_revision) - INTO max_revision - FROM identity_enrollment_policies - WHERE community_id = NEW.community_id; - - IF max_revision IS NOT NULL - AND NEW.policy_revision <= max_revision - THEN - RAISE EXCEPTION - 'policy_revision % does not strictly exceed current maximum % for community %', - NEW.policy_revision, max_revision, NEW.community_id - USING ERRCODE = 'check_violation', - CONSTRAINT = 'identity_enrollment_policy_revision_monotonic'; - END IF; - - RETURN NEW; -END; -$$ LANGUAGE plpgsql; - -CREATE FUNCTION nip_fi_reject_row_mutation_v1() RETURNS TRIGGER AS $$ -BEGIN - RAISE EXCEPTION '% is immutable', TG_TABLE_NAME - USING ERRCODE = 'check_violation'; -END; -$$ LANGUAGE plpgsql; - -CREATE FUNCTION nip_fi_reject_truncate_v1() RETURNS TRIGGER AS $$ -BEGIN - RAISE EXCEPTION '% cannot be truncated', TG_TABLE_NAME - USING ERRCODE = 'check_violation'; -END; -$$ LANGUAGE plpgsql; - --- Every binding/selector path derives the same domain-scoped coordinates and --- takes their signed BIGINT advisory keys in numeric order. Typed transaction --- APIs take these locks before row mutation; the triggers are the fail-closed --- backstop for direct SQL. -CREATE FUNCTION identity_lifecycle_lock_coordinates_v1( - locked_community_id UUID, - locked_principal_fingerprint BYTEA, - locked_event_author_pubkey BYTEA -) RETURNS VOID AS $$ -DECLARE - principal_lock_key BIGINT; - event_author_lock_key BIGINT; -BEGIN - IF locked_principal_fingerprint IS NOT NULL THEN - principal_lock_key := hashtextextended( - 'buzz:identity-lifecycle-coordinate:v1:principal:' - || locked_community_id::text || ':' - || encode(locked_principal_fingerprint, 'hex'), - 0 - ); - END IF; - IF locked_event_author_pubkey IS NOT NULL THEN - event_author_lock_key := hashtextextended( - 'buzz:identity-lifecycle-coordinate:v1:key:' - || locked_community_id::text || ':' - || encode(locked_event_author_pubkey, 'hex'), - 0 - ); - END IF; - - IF principal_lock_key IS NOT NULL AND event_author_lock_key IS NOT NULL THEN - PERFORM pg_advisory_xact_lock(LEAST(principal_lock_key, event_author_lock_key)); - IF principal_lock_key <> event_author_lock_key THEN - PERFORM pg_advisory_xact_lock(GREATEST(principal_lock_key, event_author_lock_key)); - END IF; - ELSIF principal_lock_key IS NOT NULL THEN - PERFORM pg_advisory_xact_lock(principal_lock_key); - ELSIF event_author_lock_key IS NOT NULL THEN - PERFORM pg_advisory_xact_lock(event_author_lock_key); - END IF; -END; -$$ LANGUAGE plpgsql; - -CREATE FUNCTION identity_bindings_insert_guard_v1() RETURNS TRIGGER AS $$ -BEGIN - PERFORM identity_lifecycle_lock_coordinates_v1( - NEW.community_id, - NEW.principal_fingerprint, - NEW.event_author_pubkey - ); - IF NEW.binding_state <> 1 - OR NEW.lifecycle_revision <> 1 - OR NEW.retirement_history_id IS NOT NULL - THEN - RAISE EXCEPTION 'identity binding birth must be Active at lifecycle revision 1' - USING ERRCODE = 'check_violation', - CONSTRAINT = 'identity_bindings_birth_state'; - END IF; - NEW.created_at := transaction_timestamp(); - NEW.updated_at := transaction_timestamp(); - RETURN NEW; -END; -$$ LANGUAGE plpgsql; - -CREATE FUNCTION identity_bindings_transition_guard_v1() RETURNS TRIGGER AS $$ -BEGIN - IF NEW IS NOT DISTINCT FROM OLD THEN - RETURN NEW; - END IF; - PERFORM identity_lifecycle_lock_coordinates_v1( - OLD.community_id, - OLD.principal_fingerprint, - OLD.event_author_pubkey - ); - IF NEW.community_id IS DISTINCT FROM OLD.community_id - OR NEW.binding_id IS DISTINCT FROM OLD.binding_id - OR NEW.binding_version IS DISTINCT FROM OLD.binding_version - OR NEW.issuer IS DISTINCT FROM OLD.issuer - OR NEW.subject IS DISTINCT FROM OLD.subject - OR NEW.principal_fingerprint IS DISTINCT FROM OLD.principal_fingerprint - OR NEW.event_author_pubkey IS DISTINCT FROM OLD.event_author_pubkey - OR NEW.binding_provenance IS DISTINCT FROM OLD.binding_provenance - OR NEW.policy_revision IS DISTINCT FROM OLD.policy_revision - OR NEW.enrollment_evidence_digest IS DISTINCT FROM OLD.enrollment_evidence_digest - OR NEW.expires_at IS DISTINCT FROM OLD.expires_at - OR NEW.birth_history_id IS DISTINCT FROM OLD.birth_history_id - OR NEW.creation_operation_id IS DISTINCT FROM OLD.creation_operation_id - OR NEW.creation_request_fingerprint IS DISTINCT FROM OLD.creation_request_fingerprint - OR NEW.created_at IS DISTINCT FROM OLD.created_at - THEN - RAISE EXCEPTION 'identity binding generation coordinates are immutable' - USING ERRCODE = 'check_violation', - CONSTRAINT = 'identity_bindings_immutable_generation'; - END IF; - IF OLD.binding_state <> 1 - OR OLD.lifecycle_revision <> 1 - OR OLD.retirement_history_id IS NOT NULL - OR NEW.binding_state <> 2 - OR NEW.lifecycle_revision <> 2 - OR NEW.retirement_history_id IS NULL - OR NEW.retirement_history_id = OLD.birth_history_id - THEN - RAISE EXCEPTION 'identity binding permits only Active/r1 to Retired/r2' - USING ERRCODE = 'check_violation', - CONSTRAINT = 'identity_bindings_active_to_retired'; - END IF; - NEW.updated_at := transaction_timestamp(); - RETURN NEW; -END; -$$ LANGUAGE plpgsql; - -CREATE FUNCTION identity_lifecycle_history_insert_guard_v1() RETURNS TRIGGER AS $$ -BEGIN - NEW.recorded_at := transaction_timestamp(); - RETURN NEW; -END; -$$ LANGUAGE plpgsql; - -CREATE FUNCTION identity_binding_history_semantics_guard_v1() RETURNS TRIGGER AS $$ -DECLARE - retirement identity_lifecycle_history%ROWTYPE; -BEGIN - IF NEW.binding_state = 2 THEN - SELECT * INTO STRICT retirement - FROM identity_lifecycle_history - WHERE community_id = NEW.community_id - AND history_id = NEW.retirement_history_id - AND old_binding_id = NEW.binding_id - AND old_binding_version = NEW.binding_version; - IF retirement.outcome_code <> 1 - OR retirement.old_prior_lifecycle_revision <> 1 - OR retirement.old_prior_state <> 1 - OR retirement.old_resulting_lifecycle_revision <> 2 - OR retirement.old_resulting_state <> 2 - THEN - RAISE EXCEPTION 'retired binding must reference its exact Active-to-Retired transition' - USING ERRCODE = 'check_violation', - CONSTRAINT = 'identity_bindings_retirement_history_semantics'; - END IF; - END IF; - RETURN NULL; -END; -$$ LANGUAGE plpgsql; - -CREATE FUNCTION identity_binding_birth_eligibility_guard_v1() RETURNS TRIGGER AS $$ -BEGIN - IF EXISTS ( - SELECT 1 - FROM identity_lifecycle_selectors selector - WHERE selector.community_id = NEW.community_id - AND ( - (selector.selector_kind = 1 - AND selector.principal_fingerprint = NEW.principal_fingerprint - AND selector.event_author_pubkey = NEW.event_author_pubkey) - OR (selector.selector_kind = 3 - AND selector.event_author_pubkey = NEW.event_author_pubkey) - ) - ) THEN - RAISE EXCEPTION 'binding birth conflicts with an effective lifecycle selector' - USING ERRCODE = 'check_violation', - CONSTRAINT = 'identity_bindings_birth_eligibility'; - END IF; - RETURN NULL; -END; -$$ LANGUAGE plpgsql; - -CREATE FUNCTION authorization_operation_receipt_history_guard_v1() RETURNS TRIGGER AS $$ -DECLARE - history_count BIGINT; - expected_count BIGINT; -BEGIN - SELECT count(*) INTO history_count - FROM identity_lifecycle_history history - WHERE history.community_id = NEW.community_id - AND history.operation_id = NEW.operation_id; - - -- Core lifecycle receipts (enroll, retire, revoke, rotate) each require - -- exactly one lifecycle-history row. Non-lifecycle receipts (protected - -- mutation, invalidation) require none. - expected_count := CASE - WHEN NEW.operation_kind IN (1, 3, 5, 6) AND NEW.outcome_code IN (1, 3) THEN 1 - ELSE 0 - END; - IF history_count <> expected_count THEN - RAISE EXCEPTION 'operation receipt requires % lifecycle history row, found %', - expected_count, history_count - USING ERRCODE = 'check_violation', - CONSTRAINT = 'authorization_operation_receipt_history_cardinality'; - END IF; - RETURN NULL; -END; -$$ LANGUAGE plpgsql; - -CREATE FUNCTION identity_lifecycle_selector_insert_guard_v1() RETURNS TRIGGER AS $$ -BEGIN - NEW.selected_at := transaction_timestamp(); - PERFORM identity_lifecycle_lock_coordinates_v1( - NEW.community_id, - CASE WHEN NEW.selector_kind = 1 THEN NEW.principal_fingerprint END, - NEW.event_author_pubkey - ); - RETURN NEW; -END; -$$ LANGUAGE plpgsql; - -CREATE FUNCTION identity_lifecycle_selector_history_guard_v1() RETURNS TRIGGER AS $$ -DECLARE - history identity_lifecycle_history%ROWTYPE; - old_binding identity_bindings%ROWTYPE; -BEGIN - SELECT * INTO STRICT history - FROM identity_lifecycle_history - WHERE community_id = NEW.community_id - AND history_id = NEW.asserted_history_id - AND operation_id = NEW.selected_by_operation_id - AND request_fingerprint = NEW.selected_by_request_fingerprint; - - IF history.old_binding_id IS NOT NULL THEN - SELECT * INTO STRICT old_binding - FROM identity_bindings - WHERE community_id = history.community_id - AND binding_id = history.old_binding_id - AND binding_version = history.old_binding_version; - END IF; - - -- A retired-pair (P) selector is asserted by retire, revoke, or rotate of a - -- named old generation; a revoked-key (Y) selector by revoke. - IF history.outcome_code <> 1 - OR (NEW.selector_kind = 1 AND ( - history.transition_kind NOT IN (3, 5, 6) - OR history.old_binding_id IS DISTINCT FROM NEW.binding_id - OR history.old_binding_version IS DISTINCT FROM NEW.binding_version - OR old_binding.principal_fingerprint IS DISTINCT FROM NEW.principal_fingerprint - OR old_binding.event_author_pubkey IS DISTINCT FROM NEW.event_author_pubkey - )) - OR (NEW.selector_kind = 3 AND ( - history.transition_kind <> 5 - OR (history.old_binding_id IS NOT NULL - AND old_binding.event_author_pubkey - IS DISTINCT FROM NEW.event_author_pubkey) - )) - THEN - RAISE EXCEPTION 'selector does not match its lifecycle transition' - USING ERRCODE = 'check_violation', - CONSTRAINT = 'identity_lifecycle_selector_history_semantics'; - END IF; - RETURN NULL; -END; -$$ LANGUAGE plpgsql; - -CREATE FUNCTION identity_lifecycle_transition_integrity_guard_v1() RETURNS TRIGGER AS $$ -DECLARE - transition identity_lifecycle_history%ROWTYPE; - old_binding_state SMALLINT; - asserted_p BIGINT; - asserted_y BIGINT; -BEGIN - IF TG_TABLE_NAME = 'identity_lifecycle_history' THEN - transition := NEW; - ELSIF TG_TABLE_NAME = 'identity_lifecycle_selectors' THEN - SELECT * INTO STRICT transition - FROM identity_lifecycle_history - WHERE community_id = NEW.community_id - AND history_id = NEW.asserted_history_id; - ELSE - SELECT * INTO STRICT transition - FROM identity_lifecycle_history - WHERE community_id = NEW.community_id - AND history_id = CASE - WHEN NEW.binding_state = 2 THEN NEW.retirement_history_id - ELSE NEW.birth_history_id - END; - END IF; - - SELECT - count(*) FILTER (WHERE selector_kind = 1), - count(*) FILTER (WHERE selector_kind = 3) - INTO asserted_p, asserted_y - FROM identity_lifecycle_selectors - WHERE community_id = transition.community_id - AND asserted_history_id = transition.history_id; - - IF transition.old_binding_id IS NOT NULL THEN - SELECT binding_state INTO STRICT old_binding_state - FROM identity_bindings - WHERE community_id = transition.community_id - AND binding_id = transition.old_binding_id - AND binding_version = transition.old_binding_version; - IF old_binding_state <> 2 THEN - RAISE EXCEPTION 'lifecycle transition old binding must be retired at commit' - USING ERRCODE = 'check_violation', - CONSTRAINT = 'identity_lifecycle_transition_integrity'; - END IF; - END IF; - - IF EXISTS ( - SELECT 1 - FROM identity_lifecycle_selectors selector - JOIN identity_bindings active - ON active.community_id = selector.community_id - AND active.binding_state = 1 - AND ( - (selector.selector_kind = 1 - AND active.principal_fingerprint = selector.principal_fingerprint - AND active.event_author_pubkey = selector.event_author_pubkey) - OR (selector.selector_kind = 3 - AND active.event_author_pubkey = selector.event_author_pubkey) - ) - WHERE selector.community_id = transition.community_id - ) THEN - RAISE EXCEPTION 'effective lifecycle selector conflicts with an active binding' - USING ERRCODE = 'check_violation', - CONSTRAINT = 'identity_lifecycle_transition_integrity'; - END IF; - - IF transition.outcome_code = 3 THEN - IF asserted_p + asserted_y <> 0 THEN - RAISE EXCEPTION 'no-op lifecycle transition cannot create selector facts' - USING ERRCODE = 'check_violation', - CONSTRAINT = 'identity_lifecycle_transition_integrity'; - END IF; - RETURN NULL; - END IF; - - -- Core selector companions per transition: - -- enroll (1): none - -- retire (3): exactly one P - -- revoke (5): one Y always; one P when a named old generation is removed - -- rotate (6): exactly one P (old generation retired) - IF (transition.transition_kind = 1 - AND (asserted_p, asserted_y) <> (0, 0)) - OR (transition.transition_kind = 3 - AND (asserted_p, asserted_y) <> (1, 0)) - OR (transition.transition_kind = 5 AND ( - (transition.old_binding_id IS NOT NULL - AND (asserted_p, asserted_y) <> (1, 1)) - OR (transition.old_binding_id IS NULL - AND (asserted_p, asserted_y) <> (0, 1)) - )) - OR (transition.transition_kind = 6 - AND (asserted_p, asserted_y) <> (1, 0)) - THEN - RAISE EXCEPTION 'lifecycle transition has incomplete or forbidden selector companions' - USING ERRCODE = 'check_violation', - CONSTRAINT = 'identity_lifecycle_transition_integrity'; - END IF; - RETURN NULL; -END; -$$ LANGUAGE plpgsql; - -CREATE TRIGGER identity_bindings_insert_guard - BEFORE INSERT ON identity_bindings - FOR EACH ROW EXECUTE FUNCTION identity_bindings_insert_guard_v1(); -CREATE TRIGGER identity_bindings_transition_guard - BEFORE UPDATE ON identity_bindings - FOR EACH ROW EXECUTE FUNCTION identity_bindings_transition_guard_v1(); -CREATE CONSTRAINT TRIGGER identity_bindings_history_semantics - AFTER INSERT OR UPDATE ON identity_bindings - DEFERRABLE INITIALLY DEFERRED - FOR EACH ROW EXECUTE FUNCTION identity_binding_history_semantics_guard_v1(); -CREATE CONSTRAINT TRIGGER identity_bindings_birth_eligibility - AFTER INSERT ON identity_bindings - DEFERRABLE INITIALLY DEFERRED - FOR EACH ROW EXECUTE FUNCTION identity_binding_birth_eligibility_guard_v1(); -CREATE CONSTRAINT TRIGGER identity_bindings_transition_integrity - AFTER INSERT OR UPDATE ON identity_bindings - DEFERRABLE INITIALLY DEFERRED - FOR EACH ROW EXECUTE FUNCTION identity_lifecycle_transition_integrity_guard_v1(); -CREATE TRIGGER identity_bindings_no_delete - BEFORE DELETE ON identity_bindings - FOR EACH ROW EXECUTE FUNCTION nip_fi_reject_row_mutation_v1(); -CREATE TRIGGER identity_bindings_no_truncate - BEFORE TRUNCATE ON identity_bindings - FOR EACH STATEMENT EXECUTE FUNCTION nip_fi_reject_truncate_v1(); - -CREATE TRIGGER identity_lifecycle_history_insert_guard - BEFORE INSERT ON identity_lifecycle_history - FOR EACH ROW EXECUTE FUNCTION identity_lifecycle_history_insert_guard_v1(); -CREATE CONSTRAINT TRIGGER authorization_operation_receipt_history_cardinality - AFTER INSERT ON authorization_operation_receipts - DEFERRABLE INITIALLY DEFERRED - FOR EACH ROW EXECUTE FUNCTION authorization_operation_receipt_history_guard_v1(); -CREATE CONSTRAINT TRIGGER identity_lifecycle_transition_integrity - AFTER INSERT ON identity_lifecycle_history - DEFERRABLE INITIALLY DEFERRED - FOR EACH ROW EXECUTE FUNCTION identity_lifecycle_transition_integrity_guard_v1(); - -CREATE TRIGGER identity_lifecycle_selector_insert_guard - BEFORE INSERT ON identity_lifecycle_selectors - FOR EACH ROW EXECUTE FUNCTION identity_lifecycle_selector_insert_guard_v1(); -CREATE CONSTRAINT TRIGGER identity_lifecycle_selector_history_semantics - AFTER INSERT ON identity_lifecycle_selectors - DEFERRABLE INITIALLY DEFERRED - FOR EACH ROW EXECUTE FUNCTION identity_lifecycle_selector_history_guard_v1(); -CREATE CONSTRAINT TRIGGER identity_lifecycle_selector_transition_integrity - AFTER INSERT ON identity_lifecycle_selectors - DEFERRABLE INITIALLY DEFERRED - FOR EACH ROW EXECUTE FUNCTION identity_lifecycle_transition_integrity_guard_v1(); - -CREATE TRIGGER authorization_operation_receipts_immutable - BEFORE UPDATE OR DELETE ON authorization_operation_receipts - FOR EACH ROW EXECUTE FUNCTION nip_fi_reject_row_mutation_v1(); -CREATE TRIGGER authorization_operation_receipts_no_truncate - BEFORE TRUNCATE ON authorization_operation_receipts - FOR EACH STATEMENT EXECUTE FUNCTION nip_fi_reject_truncate_v1(); - -CREATE TRIGGER identity_enrollment_policies_revision_guard - BEFORE INSERT ON identity_enrollment_policies - FOR EACH ROW EXECUTE FUNCTION identity_enrollment_policy_revision_guard_v1(); -CREATE TRIGGER identity_enrollment_policies_immutable - BEFORE UPDATE OR DELETE ON identity_enrollment_policies - FOR EACH ROW EXECUTE FUNCTION nip_fi_reject_row_mutation_v1(); -CREATE TRIGGER identity_enrollment_policies_no_truncate - BEFORE TRUNCATE ON identity_enrollment_policies - FOR EACH STATEMENT EXECUTE FUNCTION nip_fi_reject_truncate_v1(); - -CREATE TRIGGER identity_lifecycle_history_immutable - BEFORE UPDATE OR DELETE ON identity_lifecycle_history - FOR EACH ROW EXECUTE FUNCTION nip_fi_reject_row_mutation_v1(); -CREATE TRIGGER identity_lifecycle_history_no_truncate - BEFORE TRUNCATE ON identity_lifecycle_history - FOR EACH STATEMENT EXECUTE FUNCTION nip_fi_reject_truncate_v1(); - -CREATE TRIGGER identity_lifecycle_selectors_immutable - BEFORE UPDATE OR DELETE ON identity_lifecycle_selectors - FOR EACH ROW EXECUTE FUNCTION nip_fi_reject_row_mutation_v1(); -CREATE TRIGGER identity_lifecycle_selectors_no_truncate - BEFORE TRUNCATE ON identity_lifecycle_selectors - FOR EACH STATEMENT EXECUTE FUNCTION nip_fi_reject_truncate_v1(); - - --- ============================================================================ --- NIP-FI final-admission foundation (mirror of migration 0042). --- ============================================================================ - -CREATE TABLE authorization_invalidation_domains ( - community_id UUID NOT NULL PRIMARY KEY REFERENCES communities(id), - current_generation BIGINT NOT NULL CHECK (current_generation >= 0), - activated_at TIMESTAMPTZ NOT NULL DEFAULT transaction_timestamp(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT transaction_timestamp() -); - --- Closed selectors: 1 principal, 2 Nostr key, 3 binding, 4 session, 5 domain, --- 6 configuration revision. Selector 7 (delegated relationship) and its --- relationship-revision floor are deferred to the FI-DELEG migration. -CREATE TABLE authorization_invalidation_floors ( - community_id UUID NOT NULL REFERENCES communities(id), - selector_kind SMALLINT NOT NULL CHECK (selector_kind IN (1, 2, 3, 4, 5, 6)), - selector_fingerprint BYTEA NOT NULL CHECK (octet_length(selector_fingerprint) = 32), - floor_generation BIGINT NOT NULL CHECK (floor_generation > 0), - binding_version_floor BIGINT CHECK (binding_version_floor IS NULL OR binding_version_floor > 0), - operation_id UUID NOT NULL, - request_fingerprint BYTEA NOT NULL CHECK (octet_length(request_fingerprint) = 32), - updated_at TIMESTAMPTZ NOT NULL DEFAULT transaction_timestamp(), - PRIMARY KEY (community_id, selector_kind, selector_fingerprint), - FOREIGN KEY (community_id, operation_id, request_fingerprint) - REFERENCES authorization_operation_receipts - (community_id, operation_id, request_fingerprint) - DEFERRABLE INITIALLY DEFERRED, - CHECK ( - (selector_kind = 3 AND binding_version_floor IS NOT NULL) - OR (selector_kind <> 3 AND binding_version_floor IS NULL) - ) -); - --- Protected-object kinds: 1 domain, 2 channel, 3 repository, 4 media, --- 5 moderation target, 6 audio session. Kind 7 is retired: current binding --- status is connection-local evidence and never a durable protected object. -CREATE TABLE authorization_authority_epochs ( - community_id UUID NOT NULL REFERENCES communities(id), - object_kind SMALLINT NOT NULL CHECK (object_kind IN (1, 2, 3, 4, 5, 6)), - object_key BYTEA NOT NULL CHECK (octet_length(object_key) = 32), - authority_epoch BIGINT NOT NULL CHECK (authority_epoch > 0), - fence BYTEA NOT NULL CHECK ( - octet_length(fence) = 32 AND fence <> decode(repeat('00', 32), 'hex') - ), - operation_id UUID NOT NULL, - request_fingerprint BYTEA NOT NULL CHECK (octet_length(request_fingerprint) = 32), - updated_at TIMESTAMPTZ NOT NULL DEFAULT transaction_timestamp(), - PRIMARY KEY (community_id, object_kind, object_key), - UNIQUE ( - community_id, - object_kind, - object_key, - authority_epoch, - fence, - operation_id, - request_fingerprint - ), - FOREIGN KEY (community_id, operation_id, request_fingerprint) - REFERENCES authorization_operation_receipts - (community_id, operation_id, request_fingerprint) - DEFERRABLE INITIALLY DEFERRED -); - --- Direct-final current authority for a protected object. The authorization --- lease itself is sealed in memory and dies on restart; this durable row is the --- exact source re-fenced immediately before a protected mutation or emission. -CREATE TABLE protected_object_authority ( - community_id UUID NOT NULL REFERENCES communities(id), - object_kind SMALLINT NOT NULL CHECK (object_kind IN (1, 2, 3, 4, 5, 6)), - object_key BYTEA NOT NULL CHECK (octet_length(object_key) = 32), - capability SMALLINT NOT NULL CHECK ( - capability IN ( - 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, - 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, - 28, 29 - ) - ), - actor_pubkey BYTEA NOT NULL CHECK (octet_length(actor_pubkey) = 32), - binding_id UUID NOT NULL, - binding_version BIGINT NOT NULL CHECK (binding_version > 0), - policy_revision BIGINT NOT NULL CHECK (policy_revision > 0), - invalidation_generation BIGINT NOT NULL CHECK (invalidation_generation >= 0), - authority_epoch BIGINT NOT NULL CHECK (authority_epoch > 0), - fence BYTEA NOT NULL CHECK ( - octet_length(fence) = 32 AND fence <> decode(repeat('00', 32), 'hex') - ), - issued_at TIMESTAMPTZ NOT NULL, - expires_at TIMESTAMPTZ NOT NULL, - operation_id UUID NOT NULL, - request_fingerprint BYTEA NOT NULL CHECK (octet_length(request_fingerprint) = 32), - PRIMARY KEY (community_id, object_kind, object_key), - FOREIGN KEY (community_id, binding_id, binding_version) - REFERENCES identity_bindings (community_id, binding_id, binding_version) - DEFERRABLE INITIALLY DEFERRED, - FOREIGN KEY (community_id, operation_id, request_fingerprint) - REFERENCES authorization_operation_receipts - (community_id, operation_id, request_fingerprint) - DEFERRABLE INITIALLY DEFERRED, - FOREIGN KEY ( - community_id, - object_kind, - object_key, - authority_epoch, - fence, - operation_id, - request_fingerprint - ) REFERENCES authorization_authority_epochs ( - community_id, - object_kind, - object_key, - authority_epoch, - fence, - operation_id, - request_fingerprint - ) DEFERRABLE INITIALLY DEFERRED, - CHECK (issued_at < expires_at) -); - --- Explicit immutable-capacity policy required by Enforce mode. Hard ceilings --- match buzz-auth; installation limits must be sized explicitly below them. --- V1 has no online pruning/export/reset workflow. -CREATE TABLE authorization_event_capacity ( - community_id UUID NOT NULL PRIMARY KEY REFERENCES communities(id), - max_events_per_domain BIGINT NOT NULL CONSTRAINT authorization_event_capacity_max_events CHECK ( - max_events_per_domain BETWEEN 1 AND 10000 - ), - max_bytes_per_domain BIGINT NOT NULL CONSTRAINT authorization_event_capacity_max_bytes CHECK ( - max_bytes_per_domain BETWEEN 1 AND 16777216 - ), - max_envelope_bytes INTEGER NOT NULL CONSTRAINT authorization_event_capacity_max_envelope CHECK ( - max_envelope_bytes BETWEEN 1 AND 16384 - ), - retained_event_count BIGINT NOT NULL DEFAULT 0 CHECK (retained_event_count >= 0), - retained_envelope_bytes BIGINT NOT NULL DEFAULT 0 CHECK (retained_envelope_bytes >= 0), - -- 1 healthy, 2 audit unavailable/exhausted. Recovery/reset is not a V1 - -- online workflow; enabled runtime latches failure when insertion aborts. - health_state SMALLINT NOT NULL DEFAULT 1 CHECK (health_state IN (1, 2)), - failure_code SMALLINT CHECK (failure_code IS NULL OR failure_code IN (1, 2, 3)), - failure_observed_at TIMESTAMPTZ, - configured_at TIMESTAMPTZ NOT NULL DEFAULT transaction_timestamp(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT transaction_timestamp(), - CHECK (max_envelope_bytes <= max_bytes_per_domain), - CHECK (retained_event_count <= max_events_per_domain), - CHECK (retained_envelope_bytes <= max_bytes_per_domain), - CHECK ( - (health_state = 1 AND failure_code IS NULL AND failure_observed_at IS NULL) - OR (health_state = 2 AND failure_code IS NOT NULL AND failure_observed_at IS NOT NULL) - ) -); - --- Durable versioned pseudonymous authorization envelope. event_kind: --- 1 enrolled, 2 revoked, 3 rotated, 6 retired, 9 operator denied, --- 10 protected allowed, 11 protected denied, 14 invalidation advanced. --- The extended-lifecycle audit kinds (4 recovered, 5 principal enabled, --- 7 principal disabled, 8 admission lost) are deferred to the FI-LIFECYCLE --- migration, matching 0041's core lifecycle carve. Kinds 12 and 13 are --- retired: kind 24244 publication/withdrawal is ephemeral connection state and --- never a durable authorization event. -CREATE TABLE authorization_events ( - community_id UUID NOT NULL REFERENCES communities(id), - event_id UUID NOT NULL, - schema_version SMALLINT NOT NULL DEFAULT 1 CHECK (schema_version = 1), - event_kind SMALLINT NOT NULL CHECK ( - event_kind IN (1, 2, 3, 6, 9, 10, 11, 14) - ), - outcome_code SMALLINT NOT NULL CHECK (outcome_code IN (1, 2, 3, 4, 5)), - reason_code SMALLINT NOT NULL CHECK ( - reason_code IN (1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16) - ), - actor_kind SMALLINT NOT NULL CHECK (actor_kind IN (1, 2, 3, 4)), - actor_fingerprint BYTEA CHECK ( - actor_fingerprint IS NULL OR octet_length(actor_fingerprint) = 32 - ), - subject_fingerprint BYTEA CHECK ( - subject_fingerprint IS NULL OR octet_length(subject_fingerprint) = 32 - ), - -- Always retains attempted operation identity. Only unresolved pre-auth - -- event kind 9 omits the canonical receipt fingerprint; authenticated - -- OperatorDenied events remain linked to their exact canonical receipt. - operation_id UUID NOT NULL, - request_fingerprint BYTEA CHECK ( - request_fingerprint IS NULL OR octet_length(request_fingerprint) = 32 - ), - correlation_id UUID NOT NULL, - attempt_id UUID NOT NULL, - -- Redaction-safe pre-authentication denial identity. Present and non-zero - -- for unresolved pre-auth kind-9 events (actor_kind = 4); NULL for - -- authenticated kind-9 events (actor_kind 1-3) and all other event kinds. - -- Binds the event to the exact denial attempt's semantic_fingerprint - -- (intent_digest) for exact replay. - semantic_fingerprint BYTEA CHECK ( - semantic_fingerprint IS NULL OR octet_length(semantic_fingerprint) = 32 - ), - occurred_at TIMESTAMPTZ NOT NULL, - accepted_at TIMESTAMPTZ NOT NULL DEFAULT transaction_timestamp(), - canonical_envelope BYTEA NOT NULL CONSTRAINT authorization_events_envelope_size CHECK ( - octet_length(canonical_envelope) BETWEEN 1 AND 16384 - ), - envelope_digest BYTEA NOT NULL CHECK (octet_length(envelope_digest) = 32), - PRIMARY KEY (community_id, event_id), - UNIQUE (community_id, event_id, operation_id), - UNIQUE (community_id, event_id, event_kind, operation_id), - UNIQUE (community_id, operation_id, event_kind, attempt_id), - FOREIGN KEY (community_id, operation_id, request_fingerprint) - REFERENCES authorization_operation_receipts - (community_id, operation_id, request_fingerprint) - DEFERRABLE INITIALLY DEFERRED, - CHECK (event_id <> '00000000-0000-0000-0000-000000000000'::uuid), - CHECK (operation_id <> '00000000-0000-0000-0000-000000000000'::uuid), - CHECK (correlation_id <> '00000000-0000-0000-0000-000000000000'::uuid), - CHECK (attempt_id <> '00000000-0000-0000-0000-000000000000'::uuid), - CHECK ( - (actor_kind = 4 AND event_kind = 9 AND request_fingerprint IS NULL) - OR (actor_kind IN (1, 2, 3) AND request_fingerprint IS NOT NULL) - ), - CHECK ( - (actor_kind = 4 AND actor_fingerprint IS NULL AND subject_fingerprint IS NULL) - OR (actor_kind IN (1, 2, 3) AND actor_fingerprint IS NOT NULL) - ), - -- Unresolved pre-auth kind-9 events (actor_kind = 4) carry a non-zero - -- semantic_fingerprint; authenticated kind-9 events (actor_kind 1-3) and - -- all other event kinds must not. - CHECK ( - (event_kind = 9 AND actor_kind = 4 AND semantic_fingerprint IS NOT NULL - AND semantic_fingerprint <> decode(repeat('00', 32), 'hex')) - OR (event_kind = 9 AND actor_kind IN (1, 2, 3) AND semantic_fingerprint IS NULL) - OR (event_kind <> 9 AND semantic_fingerprint IS NULL) - ) -); - --- Credential-free pre-authentication denial attempts. The five-column key is --- exact replay identity; no row or FK occupies canonical operation/result, --- effect, authority, approval, or consumption state. -CREATE TABLE authorization_authentication_denial_attempts ( - community_id UUID NOT NULL REFERENCES communities(id), - operation_id UUID NOT NULL, - correlation_id UUID NOT NULL, - semantic_fingerprint BYTEA NOT NULL CHECK (octet_length(semantic_fingerprint) = 32), - denial_reason SMALLINT NOT NULL CHECK (denial_reason IN (1, 2, 3)), - expected_revision BIGINT NOT NULL CHECK (expected_revision > 0), - action SMALLINT NOT NULL CHECK (action IN (1, 2, 3, 4, 5, 6, 7, 8)), - reason_code SMALLINT NOT NULL CHECK ( - reason_code IN (1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16) - ), - attempt_id UUID NOT NULL, - audit_event_id UUID NOT NULL, - audit_event_kind SMALLINT NOT NULL DEFAULT 9 CHECK (audit_event_kind = 9), - recorded_at TIMESTAMPTZ NOT NULL DEFAULT transaction_timestamp(), - PRIMARY KEY ( - community_id, - operation_id, - correlation_id, - semantic_fingerprint, - denial_reason - ), - UNIQUE (community_id, audit_event_id), - FOREIGN KEY (community_id, audit_event_id, audit_event_kind, operation_id) - REFERENCES authorization_events (community_id, event_id, event_kind, operation_id) - DEFERRABLE INITIALLY DEFERRED, - FOREIGN KEY (community_id, operation_id, audit_event_kind, attempt_id) - REFERENCES authorization_events (community_id, operation_id, event_kind, attempt_id) - DEFERRABLE INITIALLY DEFERRED, - -- Canonical denial_reason ↔ reason_code binding: MissingCredential(1)↔Missing(2), - -- InvalidCredential(2)↔Invalid(3), Unauthenticated(3)↔Unauthenticated(4). - CONSTRAINT authorization_denial_reason_reason_code_binding CHECK ( - (denial_reason = 1 AND reason_code = 2) - OR (denial_reason = 2 AND reason_code = 3) - OR (denial_reason = 3 AND reason_code = 4) - ), - CHECK (operation_id <> '00000000-0000-0000-0000-000000000000'::uuid), - CHECK (correlation_id <> '00000000-0000-0000-0000-000000000000'::uuid), - CHECK (attempt_id <> '00000000-0000-0000-0000-000000000000'::uuid) -); - --- Exact per-operation authority-version attribution for restore. Empty --- manifests are valid; every stored component must advance strictly. -CREATE TABLE authorization_operation_version_delta_manifests ( - community_id UUID NOT NULL REFERENCES communities(id), - operation_id UUID NOT NULL, - request_fingerprint BYTEA NOT NULL CHECK (octet_length(request_fingerprint) = 32), - component_count INTEGER NOT NULL CHECK (component_count BETWEEN 0 AND 1024), - before_digest BYTEA NOT NULL CHECK (octet_length(before_digest) = 32), - after_digest BYTEA NOT NULL CHECK (octet_length(after_digest) = 32), - manifest_digest BYTEA NOT NULL CHECK (octet_length(manifest_digest) = 32), - recorded_at TIMESTAMPTZ NOT NULL DEFAULT transaction_timestamp(), - PRIMARY KEY (community_id, operation_id), - UNIQUE (community_id, operation_id, request_fingerprint), - FOREIGN KEY (community_id, operation_id, request_fingerprint) - REFERENCES authorization_operation_receipts - (community_id, operation_id, request_fingerprint) - DEFERRABLE INITIALLY DEFERRED -); - --- component_kind: 1 binding version, 2 policy revision, --- 3 invalidation generation, 4 authority epoch. Kind 6 (delegated-relationship --- revision) is deferred to the FI-DELEG migration and kind 7 (lifecycle-selector --- generation) to the FI-LIFECYCLE migration. Kind 5 is retired with durable --- client-status revisions; retained kinds keep their original identities. -CREATE TABLE authorization_operation_version_deltas ( - community_id UUID NOT NULL REFERENCES communities(id), - operation_id UUID NOT NULL, - component_kind SMALLINT NOT NULL CHECK (component_kind IN (1, 2, 3, 4)), - component_key BYTEA NOT NULL CHECK (octet_length(component_key) = 32), - before_version BIGINT NOT NULL CHECK (before_version >= 0), - after_version BIGINT NOT NULL, - component_digest BYTEA NOT NULL CHECK (octet_length(component_digest) = 32), - PRIMARY KEY (community_id, operation_id, component_kind, component_key), - FOREIGN KEY (community_id, operation_id) - REFERENCES authorization_operation_version_delta_manifests - (community_id, operation_id), - CHECK (after_version > before_version) -); - -CREATE FUNCTION authorization_event_capacity_before_insert_v1() RETURNS TRIGGER AS $$ -DECLARE - policy authorization_event_capacity%ROWTYPE; - envelope_bytes BIGINT; -BEGIN - SELECT * INTO policy - FROM authorization_event_capacity - WHERE community_id = NEW.community_id - FOR UPDATE; - - IF NOT FOUND THEN - RAISE EXCEPTION 'authorization event capacity policy missing' - USING ERRCODE = 'check_violation', - CONSTRAINT = 'authorization_event_capacity_policy_required'; - END IF; - IF policy.health_state <> 1 THEN - RAISE EXCEPTION 'authorization audit is unavailable' - USING ERRCODE = 'check_violation', - CONSTRAINT = 'authorization_event_capacity_health'; - END IF; - - envelope_bytes := octet_length(NEW.canonical_envelope); - IF envelope_bytes > policy.max_envelope_bytes - OR policy.retained_event_count + 1 > policy.max_events_per_domain - OR policy.retained_envelope_bytes + envelope_bytes > policy.max_bytes_per_domain - THEN - -- The INSERT and protected mutation abort together. The runtime maps - -- this stable constraint to typed CapacityExhausted and latches audit - -- health outside the rolled-back transaction. - RAISE EXCEPTION 'authorization event capacity exhausted' - USING ERRCODE = 'check_violation', - CONSTRAINT = 'authorization_event_capacity_exhausted'; - END IF; - - UPDATE authorization_event_capacity - SET retained_event_count = retained_event_count + 1, - retained_envelope_bytes = retained_envelope_bytes + envelope_bytes, - updated_at = transaction_timestamp() - WHERE community_id = NEW.community_id; - RETURN NEW; -END; -$$ LANGUAGE plpgsql; - -CREATE TRIGGER authorization_events_capacity - BEFORE INSERT ON authorization_events - FOR EACH ROW EXECUTE FUNCTION authorization_event_capacity_before_insert_v1(); - -CREATE FUNCTION authorization_invalidation_domain_guard_v1() RETURNS TRIGGER AS $$ -BEGIN - IF NEW IS NOT DISTINCT FROM OLD THEN - RETURN NEW; - END IF; - IF NEW.community_id IS DISTINCT FROM OLD.community_id - OR NEW.activated_at IS DISTINCT FROM OLD.activated_at - OR NEW.current_generation <= OLD.current_generation - OR NEW.updated_at <= OLD.updated_at - THEN - RAISE EXCEPTION 'authorization invalidation activation/generation cannot move backward' - USING ERRCODE = 'check_violation'; - END IF; - RETURN NEW; -END; -$$ LANGUAGE plpgsql; - -CREATE TRIGGER authorization_invalidation_domains_monotonic - BEFORE UPDATE ON authorization_invalidation_domains - FOR EACH ROW EXECUTE FUNCTION authorization_invalidation_domain_guard_v1(); -CREATE TRIGGER authorization_invalidation_domains_no_delete - BEFORE DELETE ON authorization_invalidation_domains - FOR EACH ROW EXECUTE FUNCTION nip_fi_reject_row_mutation_v1(); -CREATE TRIGGER authorization_invalidation_domains_no_truncate - BEFORE TRUNCATE ON authorization_invalidation_domains - FOR EACH STATEMENT EXECUTE FUNCTION nip_fi_reject_truncate_v1(); - -CREATE FUNCTION authorization_invalidation_floor_guard_v1() RETURNS TRIGGER AS $$ -BEGIN - IF NEW IS NOT DISTINCT FROM OLD THEN - RETURN NEW; - END IF; - IF NEW.community_id IS DISTINCT FROM OLD.community_id - OR NEW.selector_kind IS DISTINCT FROM OLD.selector_kind - OR NEW.selector_fingerprint IS DISTINCT FROM OLD.selector_fingerprint - OR NEW.floor_generation < OLD.floor_generation - OR COALESCE(NEW.binding_version_floor, 0) < COALESCE(OLD.binding_version_floor, 0) - OR ( - NEW.floor_generation = OLD.floor_generation - AND COALESCE(NEW.binding_version_floor, 0) - = COALESCE(OLD.binding_version_floor, 0) - ) - OR NEW.operation_id IS NOT DISTINCT FROM OLD.operation_id - OR NEW.updated_at <= OLD.updated_at - THEN - RAISE EXCEPTION 'authorization invalidation floor cannot move backward' - USING ERRCODE = 'check_violation'; - END IF; - RETURN NEW; -END; -$$ LANGUAGE plpgsql; - -CREATE TRIGGER authorization_invalidation_floors_monotonic - BEFORE UPDATE ON authorization_invalidation_floors - FOR EACH ROW EXECUTE FUNCTION authorization_invalidation_floor_guard_v1(); -CREATE TRIGGER authorization_invalidation_floors_no_delete - BEFORE DELETE ON authorization_invalidation_floors - FOR EACH ROW EXECUTE FUNCTION nip_fi_reject_row_mutation_v1(); -CREATE TRIGGER authorization_invalidation_floors_no_truncate - BEFORE TRUNCATE ON authorization_invalidation_floors - FOR EACH STATEMENT EXECUTE FUNCTION nip_fi_reject_truncate_v1(); - -CREATE FUNCTION authorization_authority_epoch_guard_v1() RETURNS TRIGGER AS $$ -BEGIN - IF NEW IS NOT DISTINCT FROM OLD THEN - RETURN NEW; - END IF; - IF NEW.community_id IS DISTINCT FROM OLD.community_id - OR NEW.object_kind IS DISTINCT FROM OLD.object_kind - OR NEW.object_key IS DISTINCT FROM OLD.object_key - OR NEW.authority_epoch <= OLD.authority_epoch - OR NEW.fence IS NOT DISTINCT FROM OLD.fence - OR NEW.operation_id IS NOT DISTINCT FROM OLD.operation_id - OR NEW.updated_at <= OLD.updated_at - THEN - RAISE EXCEPTION 'authorization authority epoch cannot move backward' - USING ERRCODE = 'check_violation'; - END IF; - RETURN NEW; -END; -$$ LANGUAGE plpgsql; - -CREATE TRIGGER authorization_authority_epochs_monotonic - BEFORE UPDATE ON authorization_authority_epochs - FOR EACH ROW EXECUTE FUNCTION authorization_authority_epoch_guard_v1(); -CREATE TRIGGER authorization_authority_epochs_no_delete - BEFORE DELETE ON authorization_authority_epochs - FOR EACH ROW EXECUTE FUNCTION nip_fi_reject_row_mutation_v1(); -CREATE TRIGGER authorization_authority_epochs_no_truncate - BEFORE TRUNCATE ON authorization_authority_epochs - FOR EACH STATEMENT EXECUTE FUNCTION nip_fi_reject_truncate_v1(); - -CREATE FUNCTION authorization_event_capacity_guard_v1() RETURNS TRIGGER AS $$ -BEGIN - IF NEW.community_id IS DISTINCT FROM OLD.community_id - OR NEW.max_events_per_domain IS DISTINCT FROM OLD.max_events_per_domain - OR NEW.max_bytes_per_domain IS DISTINCT FROM OLD.max_bytes_per_domain - OR NEW.max_envelope_bytes IS DISTINCT FROM OLD.max_envelope_bytes - OR NEW.configured_at IS DISTINCT FROM OLD.configured_at - OR NEW.retained_event_count < OLD.retained_event_count - OR NEW.retained_envelope_bytes < OLD.retained_envelope_bytes - OR NEW.updated_at < OLD.updated_at - OR (OLD.health_state = 2 AND ( - NEW.health_state <> 2 - OR NEW.failure_code IS DISTINCT FROM OLD.failure_code - OR NEW.failure_observed_at IS DISTINCT FROM OLD.failure_observed_at - )) - OR (OLD.health_state = 1 AND NEW.health_state = 1 AND ( - NEW.failure_code IS NOT NULL OR NEW.failure_observed_at IS NOT NULL - )) - THEN - RAISE EXCEPTION 'authorization event capacity cannot be reset online' - USING ERRCODE = 'check_violation'; - END IF; - RETURN NEW; -END; -$$ LANGUAGE plpgsql; - -CREATE FUNCTION protected_object_authority_guard_v1() RETURNS TRIGGER AS $$ -BEGIN - IF NEW IS NOT DISTINCT FROM OLD THEN - RETURN NEW; - END IF; - IF NEW.community_id IS DISTINCT FROM OLD.community_id - OR NEW.object_kind IS DISTINCT FROM OLD.object_kind - OR NEW.object_key IS DISTINCT FROM OLD.object_key - OR NEW.authority_epoch <= OLD.authority_epoch - OR NEW.fence IS NOT DISTINCT FROM OLD.fence - OR NEW.operation_id IS NOT DISTINCT FROM OLD.operation_id - OR NEW.issued_at <= OLD.issued_at - THEN - RAISE EXCEPTION 'protected authority replacement requires a new operation and epoch' - USING ERRCODE = 'check_violation'; - END IF; - RETURN NEW; -END; -$$ LANGUAGE plpgsql; - -CREATE TRIGGER authorization_event_capacity_monotonic - BEFORE UPDATE ON authorization_event_capacity - FOR EACH ROW EXECUTE FUNCTION authorization_event_capacity_guard_v1(); -CREATE TRIGGER authorization_event_capacity_no_delete - BEFORE DELETE ON authorization_event_capacity - FOR EACH ROW EXECUTE FUNCTION nip_fi_reject_row_mutation_v1(); -CREATE TRIGGER authorization_event_capacity_no_truncate - BEFORE TRUNCATE ON authorization_event_capacity - FOR EACH STATEMENT EXECUTE FUNCTION nip_fi_reject_truncate_v1(); - -CREATE TRIGGER authorization_events_immutable - BEFORE UPDATE OR DELETE ON authorization_events - FOR EACH ROW EXECUTE FUNCTION nip_fi_reject_row_mutation_v1(); -CREATE TRIGGER authorization_events_no_truncate - BEFORE TRUNCATE ON authorization_events - FOR EACH STATEMENT EXECUTE FUNCTION nip_fi_reject_truncate_v1(); - -CREATE TRIGGER authorization_authentication_denial_attempts_immutable - BEFORE UPDATE OR DELETE ON authorization_authentication_denial_attempts - FOR EACH ROW EXECUTE FUNCTION nip_fi_reject_row_mutation_v1(); -CREATE TRIGGER authorization_authentication_denial_attempts_no_truncate - BEFORE TRUNCATE ON authorization_authentication_denial_attempts - FOR EACH STATEMENT EXECUTE FUNCTION nip_fi_reject_truncate_v1(); - --- Bidirectional deferred guard: a kind-9 (pre-authentication denial) audit --- event must commit with exactly one denial attempt; a denial attempt must --- commit with its audit event present, kind-9, and matching semantic --- coordinates (correlation_id, reason_code, and semantic_fingerprint). Both --- directions deferred so event and attempt may be inserted in any order inside --- one transaction. The static denial_reason↔reason_code mapping is enforced --- by an immediate CHECK on the denial attempt table; the guard enforces the --- matching semantic coordinates between event and attempt. -CREATE FUNCTION authorization_denial_attempt_guard_v1() -RETURNS TRIGGER AS $$ -DECLARE - found_event_kind SMALLINT; - found_actor_kind SMALLINT; - found_request_fingerprint BYTEA; - found_correlation_id UUID; - found_reason_code SMALLINT; - found_semantic_fingerprint BYTEA; - attempt_count BIGINT; -BEGIN - IF TG_TABLE_NAME = 'authorization_events' THEN - -- Firing from the event side: only unresolved pre-auth kind-9 events - -- (actor_kind = 4) require a denial attempt row. Authenticated - -- OperatorDenied events (actor_kind 1-3) have a canonical receipt and - -- no denial attempt. - IF NEW.event_kind <> 9 OR NEW.actor_kind <> 4 THEN - RETURN NULL; - END IF; - - SELECT count(*) INTO attempt_count - FROM authorization_authentication_denial_attempts - WHERE community_id = NEW.community_id - AND audit_event_id = NEW.event_id; - - IF attempt_count <> 1 THEN - RAISE EXCEPTION - 'kind-9 audit event requires exactly one denial attempt, found %', - attempt_count - USING ERRCODE = 'check_violation', - CONSTRAINT = 'authorization_denial_attempt_event_cardinality'; - END IF; - - -- Verify semantic coordinates match between event and denial attempt. - SELECT correlation_id, reason_code, semantic_fingerprint - INTO found_correlation_id, found_reason_code, found_semantic_fingerprint - FROM authorization_authentication_denial_attempts - WHERE community_id = NEW.community_id - AND audit_event_id = NEW.event_id; - - IF found_correlation_id IS DISTINCT FROM NEW.correlation_id THEN - RAISE EXCEPTION - 'denial attempt correlation_id % does not match event correlation_id % for event %', - found_correlation_id, NEW.correlation_id, NEW.event_id - USING ERRCODE = 'check_violation', - CONSTRAINT = 'authorization_denial_attempt_semantic_binding'; - END IF; - - IF found_reason_code IS DISTINCT FROM NEW.reason_code THEN - RAISE EXCEPTION - 'denial attempt reason_code % does not match event reason_code % for event %', - found_reason_code, NEW.reason_code, NEW.event_id - USING ERRCODE = 'check_violation', - CONSTRAINT = 'authorization_denial_attempt_semantic_binding'; - END IF; - - IF found_semantic_fingerprint IS DISTINCT FROM NEW.semantic_fingerprint THEN - RAISE EXCEPTION - 'denial attempt semantic_fingerprint does not match event semantic_fingerprint for event %', - NEW.event_id - USING ERRCODE = 'check_violation', - CONSTRAINT = 'authorization_denial_attempt_semantic_binding'; - END IF; - ELSE - -- Firing from the denial-attempt side: verify the audit event is the - -- unresolved pre-auth kind-9 shape (actor_kind = 4, null receipt - -- fingerprint) and that exactly one denial attempt references it. - SELECT event_kind, actor_kind, request_fingerprint, - correlation_id, reason_code, semantic_fingerprint - INTO found_event_kind, found_actor_kind, found_request_fingerprint, - found_correlation_id, found_reason_code, - found_semantic_fingerprint - FROM authorization_events - WHERE community_id = NEW.community_id - AND event_id = NEW.audit_event_id; - - IF NOT FOUND THEN - RAISE EXCEPTION - 'denial attempt references non-existent audit event %', - NEW.audit_event_id - USING ERRCODE = 'check_violation', - CONSTRAINT = 'authorization_denial_attempt_event_kind'; - END IF; - - IF found_event_kind <> 9 THEN - RAISE EXCEPTION - 'denial attempt audit event must be kind 9, got %', - found_event_kind - USING ERRCODE = 'check_violation', - CONSTRAINT = 'authorization_denial_attempt_event_kind'; - END IF; - - -- The referenced event must be the unresolved pre-auth shape: actor_kind - -- 4 with a null receipt fingerprint. Attaching a denial attempt to an - -- authenticated OperatorDenied (actor_kind 1-3) would violate the - -- credential-free pre-authentication contract. - IF found_actor_kind <> 4 OR found_request_fingerprint IS NOT NULL THEN - RAISE EXCEPTION - 'denial attempt must reference an unresolved pre-auth kind-9 event ' - '(actor_kind 4, null request_fingerprint); got actor_kind % ' - 'and request_fingerprint % for event %', - found_actor_kind, - CASE WHEN found_request_fingerprint IS NULL THEN 'null' ELSE 'non-null' END, - NEW.audit_event_id - USING ERRCODE = 'check_violation', - CONSTRAINT = 'authorization_denial_attempt_event_kind'; - END IF; - - -- Verify semantic coordinates match. - IF found_correlation_id IS DISTINCT FROM NEW.correlation_id THEN - RAISE EXCEPTION - 'denial attempt correlation_id % does not match event correlation_id % for event %', - NEW.correlation_id, found_correlation_id, NEW.audit_event_id - USING ERRCODE = 'check_violation', - CONSTRAINT = 'authorization_denial_attempt_semantic_binding'; - END IF; - - IF found_reason_code IS DISTINCT FROM NEW.reason_code THEN - RAISE EXCEPTION - 'denial attempt reason_code % does not match event reason_code % for event %', - NEW.reason_code, found_reason_code, NEW.audit_event_id - USING ERRCODE = 'check_violation', - CONSTRAINT = 'authorization_denial_attempt_semantic_binding'; - END IF; - - IF found_semantic_fingerprint IS DISTINCT FROM NEW.semantic_fingerprint THEN - RAISE EXCEPTION - 'denial attempt semantic_fingerprint does not match event semantic_fingerprint for event %', - NEW.audit_event_id - USING ERRCODE = 'check_violation', - CONSTRAINT = 'authorization_denial_attempt_semantic_binding'; - END IF; - - SELECT count(*) INTO attempt_count - FROM authorization_authentication_denial_attempts - WHERE community_id = NEW.community_id - AND audit_event_id = NEW.audit_event_id; - - IF attempt_count <> 1 THEN - RAISE EXCEPTION - 'exactly one denial attempt must reference audit event %, found %', - NEW.audit_event_id, attempt_count - USING ERRCODE = 'check_violation', - CONSTRAINT = 'authorization_denial_attempt_event_cardinality'; - END IF; - END IF; - RETURN NULL; -END; -$$ LANGUAGE plpgsql; - -CREATE CONSTRAINT TRIGGER authorization_denial_attempt_event_cardinality - AFTER INSERT ON authorization_events - DEFERRABLE INITIALLY DEFERRED - FOR EACH ROW EXECUTE FUNCTION authorization_denial_attempt_guard_v1(); - -CREATE CONSTRAINT TRIGGER authorization_denial_event_attempt_cardinality - AFTER INSERT ON authorization_authentication_denial_attempts - DEFERRABLE INITIALLY DEFERRED - FOR EACH ROW EXECUTE FUNCTION authorization_denial_attempt_guard_v1(); - -CREATE FUNCTION authorization_operation_version_delta_cardinality_guard_v1() -RETURNS TRIGGER AS $$ -DECLARE - manifest authorization_operation_version_delta_manifests%ROWTYPE; - actual_component_count BIGINT; -BEGIN - IF TG_TABLE_NAME = 'authorization_operation_version_delta_manifests' THEN - manifest := NEW; - ELSE - SELECT * INTO STRICT manifest - FROM authorization_operation_version_delta_manifests - WHERE community_id = NEW.community_id - AND operation_id = NEW.operation_id - FOR NO KEY UPDATE; - END IF; - - SELECT count(*) INTO actual_component_count - FROM authorization_operation_version_deltas - WHERE community_id = manifest.community_id - AND operation_id = manifest.operation_id; - - IF actual_component_count <> manifest.component_count THEN - RAISE EXCEPTION 'operation version manifest declares % components, found %', - manifest.component_count, actual_component_count - USING ERRCODE = 'check_violation', - CONSTRAINT = 'authorization_operation_version_delta_cardinality'; - END IF; - RETURN NULL; -END; -$$ LANGUAGE plpgsql; - -CREATE CONSTRAINT TRIGGER authorization_operation_version_delta_manifest_cardinality - AFTER INSERT ON authorization_operation_version_delta_manifests - DEFERRABLE INITIALLY DEFERRED - FOR EACH ROW EXECUTE FUNCTION authorization_operation_version_delta_cardinality_guard_v1(); -CREATE CONSTRAINT TRIGGER authorization_operation_version_delta_component_cardinality - AFTER INSERT ON authorization_operation_version_deltas - DEFERRABLE INITIALLY DEFERRED - FOR EACH ROW EXECUTE FUNCTION authorization_operation_version_delta_cardinality_guard_v1(); - -CREATE TRIGGER authorization_operation_version_delta_manifests_immutable - BEFORE UPDATE OR DELETE ON authorization_operation_version_delta_manifests - FOR EACH ROW EXECUTE FUNCTION nip_fi_reject_row_mutation_v1(); -CREATE TRIGGER authorization_operation_version_delta_manifests_no_truncate - BEFORE TRUNCATE ON authorization_operation_version_delta_manifests - FOR EACH STATEMENT EXECUTE FUNCTION nip_fi_reject_truncate_v1(); - -CREATE TRIGGER authorization_operation_version_deltas_immutable - BEFORE UPDATE OR DELETE ON authorization_operation_version_deltas - FOR EACH ROW EXECUTE FUNCTION nip_fi_reject_row_mutation_v1(); -CREATE TRIGGER authorization_operation_version_deltas_no_truncate - BEFORE TRUNCATE ON authorization_operation_version_deltas - FOR EACH STATEMENT EXECUTE FUNCTION nip_fi_reject_truncate_v1(); - -CREATE TRIGGER protected_object_authority_no_delete - BEFORE DELETE ON protected_object_authority - FOR EACH ROW EXECUTE FUNCTION nip_fi_reject_row_mutation_v1(); -CREATE TRIGGER protected_object_authority_no_truncate - BEFORE TRUNCATE ON protected_object_authority - FOR EACH STATEMENT EXECUTE FUNCTION nip_fi_reject_truncate_v1(); -CREATE TRIGGER protected_object_authority_strict_replacement - BEFORE UPDATE ON protected_object_authority - FOR EACH ROW EXECUTE FUNCTION protected_object_authority_guard_v1(); - --- Canonical admission keeps its complete logical intent and the closed, --- credential-free application result beside the immutable receipt. This is --- what lets an identical request replay reconstruct the same typed result --- without repeating membership or other application DML. Object kinds match --- protected_object_authority: 1 domain, 2 channel, 3 repository, 4 media, --- 5 moderation target, 6 audio session. -CREATE TABLE authorization_admission_results ( - community_id UUID NOT NULL, - operation_id UUID NOT NULL, - request_fingerprint BYTEA NOT NULL CHECK (octet_length(request_fingerprint) = 32), - semantic_fingerprint BYTEA NOT NULL CHECK ( - octet_length(semantic_fingerprint) = 32 - AND semantic_fingerprint <> decode(repeat('00', 32), 'hex') - ), - object_kind SMALLINT NOT NULL CHECK (object_kind BETWEEN 1 AND 6), - object_key BYTEA NOT NULL CHECK ( - octet_length(object_key) = 32 - AND object_key <> decode(repeat('00', 32), 'hex') - ), - application_type BYTEA CHECK ( - application_type IS NULL - OR (octet_length(application_type) = 32 - AND application_type <> decode(repeat('00', 32), 'hex')) - ), - application_version SMALLINT CHECK (application_version > 0), - application_code SMALLINT CHECK (application_code > 0), - application_payload BYTEA CHECK ( - application_payload IS NULL OR octet_length(application_payload) <= 4096 - ), - application_intent_digest BYTEA CHECK ( - application_intent_digest IS NULL - OR (octet_length(application_intent_digest) = 32 - AND application_intent_digest <> decode(repeat('00', 32), 'hex')) - ), - application_effect_digest BYTEA CHECK ( - application_effect_digest IS NULL - OR (octet_length(application_effect_digest) = 32 - AND application_effect_digest <> decode(repeat('00', 32), 'hex')) - ), - application_result_digest BYTEA CHECK ( - application_result_digest IS NULL - OR (octet_length(application_result_digest) = 32 - AND application_result_digest <> decode(repeat('00', 32), 'hex')) - ), - recorded_at TIMESTAMPTZ NOT NULL DEFAULT transaction_timestamp(), - PRIMARY KEY (community_id, operation_id), - FOREIGN KEY (community_id, operation_id, request_fingerprint) - REFERENCES authorization_operation_receipts - (community_id, operation_id, request_fingerprint), - CHECK ( - (application_type IS NULL - AND application_version IS NULL - AND application_code IS NULL - AND application_payload IS NULL - AND application_intent_digest IS NULL - AND application_effect_digest IS NULL - AND application_result_digest IS NULL) - OR (application_type IS NOT NULL - AND application_version IS NOT NULL - AND application_code IS NOT NULL - AND application_payload IS NOT NULL - AND application_intent_digest IS NOT NULL - AND application_effect_digest IS NOT NULL - AND application_result_digest IS NOT NULL) - ) -); - -CREATE TRIGGER authorization_admission_results_no_update - BEFORE UPDATE OR DELETE ON authorization_admission_results - FOR EACH ROW EXECUTE FUNCTION nip_fi_reject_row_mutation_v1(); -CREATE TRIGGER authorization_admission_results_no_truncate - BEFORE TRUNCATE ON authorization_admission_results - FOR EACH STATEMENT EXECUTE FUNCTION nip_fi_reject_truncate_v1(); - --- Bidirectional deferred cardinality guard: a kind-11 (protected-mutation) --- receipt must commit with exactly one admission result; an admission result --- must commit against a kind-11 receipt. Deferred so receipt and result may --- be inserted in any order inside one transaction. -CREATE FUNCTION authorization_admission_result_guard_v1() -RETURNS TRIGGER AS $$ -DECLARE - receipt authorization_operation_receipts%ROWTYPE; - result_count BIGINT; -BEGIN - IF TG_TABLE_NAME = 'authorization_operation_receipts' THEN - receipt := NEW; - ELSE - -- Firing from authorization_admission_results: look up the receipt. - SELECT * INTO receipt - FROM authorization_operation_receipts - WHERE community_id = NEW.community_id - AND operation_id = NEW.operation_id; - IF NOT FOUND THEN - -- FK on the result table already guards the non-existent receipt - -- case; this path should not occur in normal operation. - RAISE EXCEPTION - 'admission result references non-existent receipt for operation %', - NEW.operation_id - USING ERRCODE = 'check_violation', - CONSTRAINT = 'authorization_admission_result_receipt_kind'; - END IF; - END IF; - - -- Non-kind-11 receipts require no admission result. - IF receipt.operation_kind <> 11 THEN - -- If this fired from the result side and the receipt is not kind 11, - -- the result is attaching to the wrong receipt kind. - IF TG_TABLE_NAME = 'authorization_admission_results' THEN - RAISE EXCEPTION - 'admission result may only attach to a kind-11 (protected-mutation) receipt, got kind %', - receipt.operation_kind - USING ERRCODE = 'check_violation', - CONSTRAINT = 'authorization_admission_result_receipt_kind'; - END IF; - RETURN NULL; - END IF; - - SELECT count(*) INTO result_count - FROM authorization_admission_results - WHERE community_id = receipt.community_id - AND operation_id = receipt.operation_id; - - IF result_count <> 1 THEN - RAISE EXCEPTION - 'kind-11 receipt requires exactly one admission result, found %', - result_count - USING ERRCODE = 'check_violation', - CONSTRAINT = 'authorization_admission_result_cardinality'; - END IF; - RETURN NULL; -END; -$$ LANGUAGE plpgsql; - -CREATE CONSTRAINT TRIGGER authorization_admission_result_receipt_cardinality - AFTER INSERT ON authorization_operation_receipts - DEFERRABLE INITIALLY DEFERRED - FOR EACH ROW EXECUTE FUNCTION authorization_admission_result_guard_v1(); - -CREATE CONSTRAINT TRIGGER authorization_admission_result_result_cardinality - AFTER INSERT ON authorization_admission_results - DEFERRABLE INITIALLY DEFERRED - FOR EACH ROW EXECUTE FUNCTION authorization_admission_result_guard_v1(); - --- Every successful/no-op core lifecycle receipt has exactly one privacy-safe --- audit event with the closed transition-kind mapping. Both directions are --- deferred so receipt, history, event, selectors, and binding may be inserted --- in any order inside one transaction but can never commit partially. The --- extended-lifecycle operation kinds (2 provision, 4 disable, 7 recover, --- 8 enable, 9 admission loss) and their event kinds arrive with the --- FI-LIFECYCLE migration; here the mapping covers only enroll/retire/revoke/ --- rotate. Non-lifecycle receipts (protected mutation, invalidation) carry no --- audit-event cardinality requirement. -CREATE FUNCTION authorization_operation_receipt_event_guard_v1() -RETURNS TRIGGER AS $$ -DECLARE - receipt authorization_operation_receipts%ROWTYPE; - expected_event_kind SMALLINT; - matching_event_count BIGINT; - expected_event_count BIGINT; -BEGIN - IF TG_TABLE_NAME = 'authorization_operation_receipts' THEN - receipt := NEW; - ELSE - SELECT * INTO receipt - FROM authorization_operation_receipts - WHERE community_id = NEW.community_id - AND operation_id = NEW.operation_id; - IF NOT FOUND THEN - -- Credential-free pre-authentication denials intentionally have no - -- canonical receipt. Their separate FK/shape guards still run. - RETURN NULL; - END IF; - END IF; - - expected_event_kind := CASE receipt.operation_kind - WHEN 1 THEN 1 -- enroll - WHEN 3 THEN 6 -- retire - WHEN 5 THEN 2 -- revoke - WHEN 6 THEN 3 -- rotate - ELSE NULL - END; - IF expected_event_kind IS NULL THEN - RETURN NULL; - END IF; - - -- Only applied (outcome_code = 1) and no-op (outcome_code = 3) lifecycle - -- receipts require exactly one paired success-transition event. A denied - -- lifecycle receipt (outcome_code = 2) requires zero events from the - -- complete core lifecycle success-transition class (kinds 1, 2, 3, 6: - -- enrolled, revoked, rotated, retired). Forbidding only the mapped kind - -- would allow a wrong-kind transition event to attach to the denied receipt, - -- which is equally a contradictory durable fact. Legitimate audit/denial - -- events of other kinds (e.g., authenticated kind 9) remain allowed. - -- Other outcome codes (4, 5) are not core lifecycle outcomes; skip. - IF receipt.outcome_code IN (1, 3) THEN - SELECT - count(*), - count(*) FILTER (WHERE event_kind = expected_event_kind) - INTO matching_event_count, expected_event_count - FROM authorization_events - WHERE community_id = receipt.community_id - AND operation_id = receipt.operation_id - AND request_fingerprint = receipt.request_fingerprint; - - IF matching_event_count <> 1 OR expected_event_count <> 1 THEN - RAISE EXCEPTION - 'lifecycle receipt requires exactly one event kind %, found % total and % expected', - expected_event_kind, matching_event_count, expected_event_count - USING ERRCODE = 'check_violation', - CONSTRAINT = 'authorization_operation_receipt_event_cardinality'; - END IF; - ELSIF receipt.outcome_code = 2 THEN - SELECT count(*) FILTER (WHERE event_kind IN (1, 2, 3, 6)) - INTO expected_event_count - FROM authorization_events - WHERE community_id = receipt.community_id - AND operation_id = receipt.operation_id - AND request_fingerprint = receipt.request_fingerprint; - - IF expected_event_count <> 0 THEN - RAISE EXCEPTION - 'denied lifecycle receipt must not have any core success-transition event ' - '(kinds 1/2/3/6); found % — contradictory durable facts are not permitted', - expected_event_count - USING ERRCODE = 'check_violation', - CONSTRAINT = 'authorization_denied_lifecycle_receipt_no_success_event'; - END IF; - END IF; - RETURN NULL; -END; -$$ LANGUAGE plpgsql; - -CREATE CONSTRAINT TRIGGER authorization_operation_receipt_event_cardinality - AFTER INSERT ON authorization_operation_receipts - DEFERRABLE INITIALLY DEFERRED - FOR EACH ROW EXECUTE FUNCTION authorization_operation_receipt_event_guard_v1(); - -CREATE CONSTRAINT TRIGGER authorization_event_receipt_cardinality - AFTER INSERT ON authorization_events - DEFERRABLE INITIALLY DEFERRED - FOR EACH ROW EXECUTE FUNCTION authorization_operation_receipt_event_guard_v1(); From 0dbd036f5bff33e7ade75e7639f3218d424a6e73 Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Wed, 2 Sep 2026 14:25:30 -0400 Subject: [PATCH 04/13] feat(agents): harness-agnostic effort write path and spawn bridge (#4625) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Genericizes the agent effort write-side so Goose participates in the same canonical effort contract as buzz-agent. A spawn bridge translates the canonical key to whatever the target harness expects at launch time. Read/write/spawn paths all derive their vocabulary from runtime metadata rather than a hardcoded buzz-agent list. ## What changed ### Rust — config bridge + spawn path - `apply_spawn_effort_env` in `effort.rs`: production command-boundary seam — writes baked env, runs the effort projection, strips per-runtime suppress set, and emits exactly one projected key. - `apply_effort_to_spawn_command` in `runtime.rs`: thin wrapper returning a `#[must_use] EffortApplied(())` token (private field — unforgeable outside the function). `spawn_agent_child` calls it as `let effort = apply_effort_to_spawn_command(...)` and passes `effort` to `spawn_with_effort_proof`. Deleting the call is a compile error: `effort` is undefined at the `spawn_with_effort_proof` site. Deleting `apply_spawn_effort_env` inside the wrapper turns the production-sequence tests RED. - `apply_record_field_updates` in `agent_models_update.rs`: returns `Result` (`#[must_use]` token). `update_managed_agent` calls it as `let applied = apply_record_field_updates(...)?` then passes `applied` to `stamp_record_updated_at`. Deleting the call is a compile error: `applied` is undefined at the `stamp_record_updated_at` site. - Unknown/custom-runtime passthrough: `apply_effort_launch_to_command` skips the suppress loop when `preserve_passthrough && value.is_none()`, preserving ambient ACP sentinels. - `EnvVarGuard`: prior value stored as `OsString` (`var_os`) so non-Unicode values are restored exactly on Drop. A single `PROCESS_ENV_MUTEX` in `managed_agents/mod.rs` is shared by `lock_path_mutex()` and `lock_env_mutex()` — any two tests calling either helper are mutually exclusive with each other. Tests in other modules (`app_state_tests`, `agent_config_tests`, `reader_tests`) maintain their own independent locks and are not in this domain. - Dead-code: `strip_effort_keys_from_command` marked `#[cfg(test)]`; import path in `effort_cmd_tests.rs` fixed. - Windows CI fix: platform-gated variants for inherited-env tests. ### TypeScript — renderer + model cleanup - `AgentConfigFields` orphan-model cleanup effect: the `isHarnessNativeEffort` early-return was skipping the model clear on provider→Custom transitions. Refined to: return early only when model is already null; clear model once while preserving the harness-native effort key (Carl P2). - Provider-empty convergence: when model is null and effort is native, the cleanup effect returns early (nothing to clear) — prevents spurious `onConfigChange` loop. - `EffortSelectField` / `humanizeEffortLabel`: runtime-native option labels title-cased (`off` → `Off`) with raw canonical values preserved for round-trip fidelity. - `AgentConfigFields`: drives effort renderer from `selectedRuntime.effortCanonicalValues` (harness-native path) or the model/provider catalog (buzz-agent/provider path), selected by `isHarnessNativeEffort`. ### Docs - `desktop/src/features/agents/AGENTS.md` item 14: updated from deleted `persistAgentEffortLevel` direct-write contract to the shipped Save-gated `update_managed_agent.effortLevel` path. Consistent with `EffortPickerField`'s own doc comment. ### Tests - `agent_models_update_tests.rs`: seam tests via `apply_record_field_updates` — non-local rejects, local set/clear, ordering invariant, ACP-sentinel sweep. `record_field_updates_persist_effort_to_disk` (renamed from the prior false-claim name) drives load→apply→stamp→save→load via a mock AppHandle + tempdir, asserting `effort_level` persists to disk. Manual HOME/XDG restore replaced with RAII `EnvVarGuard` (panic-safe, `OsString`-exact). - `effort_cmd_tests.rs` / `effort_tests.rs`: production-sequence seam tests via `apply_effort_to_spawn_command`. Spawns `/usr/bin/env` to verify child's real env. `EnvVarGuard` for panic-safe restore. Windows twin using `cmd /c set`. - `effortAutoClear.test.mjs`: five mounted stateful journeys via `AgentConfigFields` with `useCustomSelect=true`. Covers: custom trigger shows "Off" at mount; provider-empty mount is a stable fixed point; provider→Custom switch converges; stale Anthropic model cleared on Custom switch with Goose effort preserved (Carl P2 regression); Settings-style Save/reread preserves effort. - `agentDefaultsEditor.test.mjs`: two full Save/Next journey tests through the real production parent trees. Both start with `GOOSE_THINKING_EFFORT: "low"` and operate the real Popover-based effort control (click trigger → click "off" option) before Save/Next, asserting zero writes after selection. The `set_global_agent_config` stub captures the submitted payload; each test asserts raw `GOOSE_THINKING_EFFORT: "off"` in the captured config. The stub stores its canonical response from the actual payload; the fresh remount's `get_global_agent_config` returns that stored object (not a hand-written fixture), then asserts "Off" shown. The `DefaultConfigStep` test starts with `isDirty: false` — the real-control effort selection calls `onConfigChange → updateDraft → isDirtyRef=true`, making the `commit()` on Next load-bearing. ## Mutation evidence - Delete `let effort = apply_effort_to_spawn_command(...)` call from `spawn_agent_child` → compile error: `error[E0425]: cannot find value `effort`` at `spawn_with_effort_proof` site. - Delete `let applied = apply_record_field_updates(...)?` from `update_managed_agent` → compile error: `error[E0425]: cannot find value `applied`` at `stamp_record_updated_at` site. - Delete `apply_spawn_effort_env` from inside `apply_effort_to_spawn_command` wrapper → `production_sequence_goose_inherited_collision_resolved_in_child` RED. - Revert `isHarnessNativeEffort &&` guard in cleanup `useEffect` to bare `if (isHarnessNativeEffort) return` → stale model not cleared → Carl P2 regression test RED. - Remove `isHarnessNativeEffort ||` from the nothing-to-clear condition → provider-empty mount emits `onConfigChange` → loop test RED. - Remove `isHarnessNativeEffort` branch in `AgentConfigFields.tsx:634-636` → both `agentDefaultsEditor.test.mjs` mount assertions fail: trigger shows "Select" instead of initial effort label. - Remove `preserve_passthrough` guard in `apply_effort_launch_to_command` → `production_sequence_custom_inherited_acp_sentinel_survives` RED. - Drop `GOOSE_THINKING_EFFORT` from the `set_global_agent_config` stub payload → payload assertion in `agentDefaultsEditor.test.mjs` fails (`undefined !== "off"`) → RED (verified). - Remove the effort-select dirtying steps from the `DefaultConfigStep` test (so `isDirty` stays false) → `commit()` is a no-op → write-count assertion after Next fails (0 instead of 1) → RED. --------- Signed-off-by: Will Pfleger Signed-off-by: Duncan Signed-off-by: Hayt <9e1c23a3fd83f61da34420e4e88ff1b16e45cafcc0cd9019eb07d4ecfa8ca9b0@buzz.block.builderlab.xyz> Co-authored-by: Duncan Co-authored-by: Hayt <9e1c23a3fd83f61da34420e4e88ff1b16e45cafcc0cd9019eb07d4ecfa8ca9b0@buzz.block.builderlab.xyz> --- .../src-tauri/src/commands/agent_config.rs | 46 +- .../src/commands/agent_config_tests.rs | 59 +- .../src-tauri/src/commands/agent_discovery.rs | 2 +- .../src-tauri/src/commands/agent_models.rs | 1 - .../src/commands/agent_models_update.rs | 122 +- .../src/commands/agent_models_update_tests.rs | 342 ++++++ .../src-tauri/src/commands/agents_deploy.rs | 84 +- desktop/src-tauri/src/lib.rs | 1 - .../src/managed_agents/claude_config/mod.rs | 28 +- .../src/managed_agents/claude_config/tests.rs | 78 +- .../managed_agents/config_bridge/effort.rs | 506 ++++++++ .../config_bridge/effort_cmd_tests.rs | 356 ++++++ .../config_bridge/effort_tests.rs | 701 +++++++++++ .../src/managed_agents/config_bridge/mod.rs | 20 + .../managed_agents/config_bridge/reader.rs | 166 ++- .../config_bridge/reader_tests.rs | 12 +- .../config_bridge/reader_tests_ext.rs | 457 +++++++ .../config_bridge/reader_tests_ext2.rs | 69 ++ .../src-tauri/src/managed_agents/discovery.rs | 153 +-- .../src/managed_agents/discovery/catalog.rs | 156 +++ .../src/managed_agents/discovery/overrides.rs | 74 +- .../src/managed_agents/discovery/presets.rs | 1 + .../discovery/runtime_metadata.rs | 95 ++ .../src/managed_agents/discovery/tests.rs | 57 +- .../discovery/tests/effort_clear.rs | 188 +++ desktop/src-tauri/src/managed_agents/mod.rs | 20 +- .../src-tauri/src/managed_agents/readiness.rs | 72 +- .../readiness/tests/openrouter_tests.rs | 57 + .../src-tauri/src/managed_agents/runtime.rs | 135 ++- .../src/managed_agents/spawn_snapshot.rs | 87 +- .../spawn_snapshot/tests_ext.rs | 334 +++-- desktop/src-tauri/src/managed_agents/types.rs | 22 +- .../src/managed_agents/types/requests.rs | 10 + desktop/src/features/agents/AGENTS.md | 31 +- .../agents/lib/agentConfigCore.test.mjs | 170 ++- .../features/agents/lib/agentConfigCore.ts | 24 +- .../features/agents/ui/AgentConfigFields.tsx | 100 +- .../agents/ui/AgentInstanceEditDialog.tsx | 306 +++-- .../agents/ui/EditAgentAdvancedFields.tsx | 4 + .../ui/EditAgentProviderModelFields.tsx | 191 +++ .../features/agents/ui/EffortPickerField.tsx | 54 +- .../agents/ui/agentDefaultsEditor.test.mjs | 705 +++++++++++ .../ui/agentInstanceEditCancelSafety.test.mjs | 1078 +++++++++++++++++ .../ui/agentInstanceEditPinning.test.mjs | 119 ++ .../agents/ui/buzzAgentModelTuningFields.tsx | 38 +- .../agents/ui/effortAutoClear.test.mjs | 509 ++++++++ .../src/features/agents/ui/effortPicker.ts | 20 +- .../features/agents/ui/personaRuntimeModel.ts | 31 + .../agents/ui/providerEnvVarUpdates.test.mjs | 24 + .../agents/ui/providerEnvVarUpdates.ts | 17 + .../ui/runtimeModelProviderSelection.test.mjs | 37 + .../ui/runtimeModelProviderSelection.ts | 36 + desktop/src/shared/api/tauri.ts | 4 +- desktop/src/shared/api/tauriManagedAgents.ts | 15 - desktop/src/shared/api/types.ts | 20 +- 55 files changed, 7123 insertions(+), 921 deletions(-) create mode 100644 desktop/src-tauri/src/managed_agents/config_bridge/effort.rs create mode 100644 desktop/src-tauri/src/managed_agents/config_bridge/effort_cmd_tests.rs create mode 100644 desktop/src-tauri/src/managed_agents/config_bridge/effort_tests.rs create mode 100644 desktop/src-tauri/src/managed_agents/config_bridge/reader_tests_ext2.rs create mode 100644 desktop/src-tauri/src/managed_agents/discovery/catalog.rs create mode 100644 desktop/src-tauri/src/managed_agents/discovery/tests/effort_clear.rs create mode 100644 desktop/src-tauri/src/managed_agents/readiness/tests/openrouter_tests.rs create mode 100644 desktop/src/features/agents/ui/EditAgentProviderModelFields.tsx create mode 100644 desktop/src/features/agents/ui/agentDefaultsEditor.test.mjs create mode 100644 desktop/src/features/agents/ui/agentInstanceEditCancelSafety.test.mjs create mode 100644 desktop/src/features/agents/ui/effortAutoClear.test.mjs diff --git a/desktop/src-tauri/src/commands/agent_config.rs b/desktop/src-tauri/src/commands/agent_config.rs index 4df24e6e9b..16b4c93b3e 100644 --- a/desktop/src-tauri/src/commands/agent_config.rs +++ b/desktop/src-tauri/src/commands/agent_config.rs @@ -14,9 +14,8 @@ use crate::{ }, current_instance_id, is_reserved_env_key, is_safe_to_reveal, is_well_formed_env_key, known_acp_runtime, load_managed_agents, load_personas, resolve_effective_agent_env, - save_managed_agents, sync_managed_agent_processes, AgentDefinition, BackendKind, - GlobalAgentConfig, KnownAcpRuntime, ManagedAgentRecord, ManagedAgentRuntimeKey, - MAX_ENV_VALUE_BYTES, + save_managed_agents, sync_managed_agent_processes, AgentDefinition, GlobalAgentConfig, + KnownAcpRuntime, ManagedAgentRecord, ManagedAgentRuntimeKey, MAX_ENV_VALUE_BYTES, }, }; @@ -535,42 +534,15 @@ fn parse_models(raw: Option<&serde_json::Value>) -> (Vec, Option< (models, current_model) } -/// Persist the canonical startup effort level for a local managed agent. -/// -/// B5 (v4 direct-write): the panel's EffortPicker calls this directly to set the -/// effort a spawn will apply at next session start. The value is stored on the -/// record; at spawn `runtime.rs` injects it as `BUZZ_ACP_EFFORT_LEVEL` and the -/// harness applies it via `session/set_config_option` against the adapter's -/// advertised `thought_level` configId. Pass `None` to clear (adapter default). -/// -/// Rejects non-local backends: remote agents receive effort through `policy_env` -/// at deploy time (see `agents_deploy.rs`), never this local persistence path — -/// so an effort edit against a deployed agent is a caller error, not a silent -/// no-op that leaves the panel and the running agent disagreeing. -#[tauri::command] -pub fn persist_agent_effort_level( - pubkey: String, +/// Atomically set the record's canonical effort column and strip every stale +/// record-scope effort env alias. Split from the Tauri command so the invariant +/// — no leftover alias can outrank the just-set column — is directly testable. +pub(crate) fn apply_picker_effort_level( + record: &mut ManagedAgentRecord, effort_level: Option, - app: AppHandle, - state: State<'_, AppState>, -) -> Result<(), String> { - let _store_guard = state - .managed_agents_store_lock - .lock() - .map_err(|e| e.to_string())?; - let mut records = load_managed_agents(&app)?; - let record = records - .iter_mut() - .find(|r| r.pubkey == pubkey) - .ok_or_else(|| format!("agent {pubkey} not found"))?; - if record.backend != BackendKind::Local { - return Err(format!( - "agent {pubkey} is not a local agent; remote effort is set at deploy time" - )); - } +) { record.effort_level = effort_level; - record.updated_at = crate::util::now_iso(); - save_managed_agents(&app, &records) + crate::managed_agents::remove_record_effort_aliases(&mut record.env_vars); } #[cfg(test)] diff --git a/desktop/src-tauri/src/commands/agent_config_tests.rs b/desktop/src-tauri/src/commands/agent_config_tests.rs index 596d9d3629..093e925f18 100644 --- a/desktop/src-tauri/src/commands/agent_config_tests.rs +++ b/desktop/src-tauri/src/commands/agent_config_tests.rs @@ -29,7 +29,7 @@ fn with_no_goose_config(body: impl FnOnce() -> T) -> T { } fn goose_runtime() -> &'static KnownAcpRuntime { - &KnownAcpRuntime { + static RUNTIME: KnownAcpRuntime = KnownAcpRuntime { id: "goose", label: "Goose", commands: &["goose"], @@ -55,13 +55,16 @@ fn goose_runtime() -> &'static KnownAcpRuntime { config_file_format: Some("yaml"), supports_acp_native_config: true, thinking_env_var: Some("GOOSE_THINKING_EFFORT"), + effort_normalization: Some(&crate::managed_agents::GOOSE_EFFORT_NORMALIZATION), + effort_accepted_values: None, max_tokens_env_var: Some("GOOSE_MAX_TOKENS"), context_limit_env_var: Some("GOOSE_CONTEXT_LIMIT"), max_rounds_env_var: None, required_normalized_fields: &["model", "provider"], login_hint: None, auth_probe_args: None, - } + }; + &RUNTIME } fn agent_record() -> ManagedAgentRecord { @@ -630,6 +633,58 @@ fn baked_env_mixed_keys_correct_masking() { assert!(token.masked); } +/// F1 picker direct-write invariant: a stale record-native `GOOSE_THINKING_EFFORT` +/// (launch-projection tier 1, ABOVE the canonical column) must not survive a +/// picker write. Setting effort `high` through the picker path both writes the +/// column and sweeps the stale alias, so the reader and the launch projection +/// both resolve `high` — not the stale `low`. Deleting the sweep in +/// `apply_picker_effort_level` re-breaks this: the projection would emit `low`. +#[test] +fn picker_write_sweeps_stale_record_native_effort_alias() { + let mut record = agent_record(); + record + .env_vars + .insert("GOOSE_THINKING_EFFORT".to_string(), "low".to_string()); + + super::apply_picker_effort_level(&mut record, Some("high".to_string())); + + // The stale record-native alias is gone; only the column carries the value. + assert!( + !record.env_vars.contains_key("GOOSE_THINKING_EFFORT"), + "stale record-native effort alias must be swept by the picker write" + ); + assert_eq!(record.effort_level.as_deref(), Some("high")); + + // Reader: the panel resolves the just-set value, not the stale alias. + let surface = with_no_goose_config(|| { + resolve_config_surface( + record.clone(), + &[], + Some(goose_runtime()), + None, + &Default::default(), + None, + ) + }); + let effort = surface + .normalized + .thinking_effort + .expect("picker-set effort must resolve"); + assert_eq!(effort.value.as_deref(), Some("high")); + + // Launch projection: the spawned child receives the picker value. + let launch = crate::managed_agents::config_bridge::effort::effort_launch_projection( + &record, + Some(goose_runtime()), + &[], + None, + &std::collections::BTreeMap::new(), + None, + &std::collections::BTreeMap::new(), + ); + assert_eq!(launch.value.as_deref(), Some("high")); +} + #[test] fn baked_env_thinking_effort_is_unmasked() { // BUZZ_AGENT_THINKING_EFFORT is a non-secret enum — must not be masked. diff --git a/desktop/src-tauri/src/commands/agent_discovery.rs b/desktop/src-tauri/src/commands/agent_discovery.rs index 95e9759f10..ccca7c4abf 100644 --- a/desktop/src-tauri/src/commands/agent_discovery.rs +++ b/desktop/src-tauri/src/commands/agent_discovery.rs @@ -17,7 +17,6 @@ fn active_installs() -> &'static std::sync::Mutex>, +) -> Result<(), String> { + if effort_level.is_some() && record.backend != crate::managed_agents::BackendKind::Local { + return Err(format!( + "agent {} is not a local agent; remote effort is set at deploy time", + record.pubkey + )); + } + Ok(()) +} + +/// Guard/apply seam for the effort step inside `apply_record_field_updates`. +fn apply_effort_update( + record: &mut ManagedAgentRecord, + effort_level: Option>, +) -> Result<(), String> { + ensure_effort_change_supported(record, &effort_level)?; + if let Some(effort_override) = effort_level { + crate::commands::agent_config::apply_picker_effort_level(record, effort_override); + } + Ok(()) +} + +/// Proof token returned by `apply_record_field_updates`. Zero-size and +/// `#[must_use]`; consumed by `stamp_record_updated_at`, so removing the +/// `apply_record_field_updates` call from `update_managed_agent` leaves +/// `applied` undefined at the timestamp site — a compile error. +#[derive(Debug)] +#[must_use] +pub(crate) struct RecordFieldsApplied(()); + +/// Apply the env-vars and effort steps of `update_managed_agent` to a record +/// in the correct order: env_vars FIRST (so the same-request map cannot +/// reintroduce a stale alias), then the canonical effort column write. +/// +/// Returns a `RecordFieldsApplied` token that must be passed to +/// `stamp_record_updated_at`. Removing this call from `update_managed_agent` +/// leaves `applied` undefined at the timestamp site — a compile error. +/// +/// Called by `update_managed_agent` inside its locked transaction and by tests. +/// Any step deleted from inside this function is directly caught by the +/// corresponding test assertion. +/// +/// Mutation proofs (see `agent_models_update_tests.rs`): +/// - Deleting the `apply_effort_update` call leaves `effort_level` unchanged. +/// - Deleting `ensure_effort_change_supported` inside `apply_effort_update` +/// lets non-local writes pass `Ok(())` without mutating the column. +/// - Deleting `apply_picker_effort_level` inside `apply_effort_update` +/// leaves `effort_level == None` on a local-set request. +pub(crate) fn apply_record_field_updates( + record: &mut ManagedAgentRecord, + env_vars: Option<&std::collections::BTreeMap>, + inherit_transition: bool, + effort_level: Option>, +) -> Result { + // Order is load-bearing: env_vars before effort so a same-request + // env_vars map cannot reintroduce a stale alias after the column write. + crate::managed_agents::apply_env_vars_then_effort_transition( + record, + env_vars.cloned(), + inherit_transition, + ); + apply_effort_update(record, effort_level)?; + Ok(RecordFieldsApplied(())) +} + +/// Stamp `record.updated_at` with the current ISO timestamp, consuming the +/// `RecordFieldsApplied` proof token. Removing `apply_record_field_updates` +/// from `update_managed_agent` leaves `applied` undefined here — a compile error. +pub(crate) fn stamp_record_updated_at( + record: &mut ManagedAgentRecord, + _applied: RecordFieldsApplied, +) { + record.updated_at = crate::util::now_iso(); +} + /// Flush a retained managed-agent policy, preserving any earlier profile error. pub(crate) async fn flush_managed_agent_policy( app: &AppHandle, @@ -115,15 +197,17 @@ pub async fn update_managed_agent( // Harness edit: the persona's runtime is authoritative, so an explicit // `agent_command_override` is persisted ONLY when the user picks a // command that diverges from the persona, and the empty/whitespace - // "Inherit from persona" sentinel clears both the pin and the - // materialized record runtime. A name-only edit + // "Inherit from persona" sentinel clears the pin, the materialized + // record runtime, AND the per-instance effort override (column here, + // env aliases after `env_vars` is applied below). A name-only edit // (`agent_command == None`) leaves the pin intact. `harness_override` // threads the user's explicit intent — see `apply_agent_command_update` // and `update_time_agent_command_override` for the full resolution // rules. + let mut inherit_transition = false; if let Some(agent_command) = input.agent_command { let personas = load_personas(&app).unwrap_or_default(); - crate::managed_agents::apply_agent_command_update( + inherit_transition = crate::managed_agents::apply_agent_command_update( record, &personas, &agent_command, @@ -136,9 +220,16 @@ pub async fn update_managed_agent( // mcp_command is intentionally not applied here — the effective MCP // command is always catalog-derived (known_acp_runtime at spawn time) // and the per-record field is never read by the runtime. - if let Some(env_vars) = input.env_vars { - crate::managed_agents::validate_user_env_keys(&env_vars)?; - record.env_vars = env_vars; + // + // Apply the caller-supplied `env_vars` (validated first), then — only on + // the pin→inherit transition — strip the record effort env aliases. The + // order is load-bearing: stripping AFTER the env replacement is what + // stops a same-request `env_vars` map from reintroducing a stale effort + // alias while the instance inherits its harness. The column was already + // cleared inside `apply_agent_command_update`. See + // `apply_env_vars_then_effort_transition` for the pinned invariant. + if let Some(ref env_vars) = input.env_vars { + crate::managed_agents::validate_user_env_keys(env_vars)?; } // Native provider/model fields are authoritative. Keep the typed marker @@ -211,7 +302,23 @@ pub async fn update_managed_agent( record.respond_to_allowlist = prospective_allowlist; } - record.updated_at = now_iso(); + // Effort + env_vars: applied together inside `apply_record_field_updates` to + // enforce the ordering invariant (env_vars before effort column write) and + // provide a directly-testable production seam. Effort persists inside the + // locked transaction so an access-policy restart above snapshots and + // launches the new effort value. Present+Some(v)=set; Present+None=clear; + // Absent=don't touch (the dialog sends it only when effortTouched). + // The returned token is consumed by `stamp_record_updated_at`; removing + // this call from `update_managed_agent` leaves `applied` undefined there + // — a compile error (the sole outer-seam proof for this call site). + let applied = apply_record_field_updates( + record, + input.env_vars.as_ref(), + inherit_transition, + input.effort_level, + )?; + + stamp_record_updated_at(record, applied); save_managed_agents(&app, &records)?; @@ -365,5 +472,6 @@ pub async fn update_managed_agent( } #[cfg(test)] +#[allow(unused_must_use)] #[path = "agent_models_update_tests.rs"] mod tests; diff --git a/desktop/src-tauri/src/commands/agent_models_update_tests.rs b/desktop/src-tauri/src/commands/agent_models_update_tests.rs index b9fd0bd183..28a50e7b15 100644 --- a/desktop/src-tauri/src/commands/agent_models_update_tests.rs +++ b/desktop/src-tauri/src/commands/agent_models_update_tests.rs @@ -1,4 +1,9 @@ use super::*; +// The tests call `apply_record_field_updates(...)` and consume the return value +// via `.expect(...)`, discarding `RecordFieldsApplied`. The tests verify column +// writes (side effects), not the token itself. The lint is suppressed here so +// callers remain readable. Production code (update_managed_agent) must never +// suppress it — the token IS the outer-seam compile-time proof. fn provider_record(deployed: bool) -> ManagedAgentRecord { let mut record: ManagedAgentRecord = serde_json::from_value(serde_json::json!({ @@ -29,3 +34,340 @@ fn undeployed_provider_accepts_access_edits() { ensure_access_policy_change_supported(&provider_record(false), true) .expect("no running provider deployment can retain stale access"); } + +fn local_record() -> ManagedAgentRecord { + serde_json::from_value(serde_json::json!({ + "pubkey": "local", "name": "Local Agent", "relay_url": "", "acp_command": "", + "agent_command": "", "agent_args": [], "mcp_command": "", + "turn_timeout_seconds": 0, "system_prompt": null, "created_at": "", + "updated_at": "", "last_started_at": null, "last_stopped_at": null, + "last_exit_code": null, "last_error": null + })) + .unwrap() + // BackendKind deserializes as Local when the field is absent (the json! above). +} + +// ── Production-entered seam tests (apply_record_field_updates) ────────────── +// +// These tests call `apply_record_field_updates`, the same function production +// calls inside `update_managed_agent` for the env_vars+effort ordered write. +// They verify: +// - non-local records are rejected AND the column is NOT mutated; +// - local set writes to the column and sweeps stale env aliases; +// - local clear zeroes the column and sweeps stale env aliases; +// - env_vars applied before effort so no same-request alias re-pins the column. +// +// Deletion proof for the effort guard: removing `ensure_effort_change_supported` +// inside `apply_record_field_updates` makes reject tests return `Ok(())` instead +// of `Err`, and the "record not mutated" assertions fail. +// +// Deletion proof for the apply call: removing the `apply_effort_update` call +// inside `apply_record_field_updates` leaves `effort_level == None` on local-set. +// +// Deletion proof for the env_vars step: removing `apply_env_vars_then_effort_transition` +// inside `apply_record_field_updates` leaves the env alias in `env_vars` on local-set. +// +// Ordering proof: `env_vars` with a stale alias is applied BEFORE effort so the +// alias is stripped; reversing the order leaves both the alias and the new column. +// +// Outer-seam proof (compile-error): removing `apply_record_field_updates` from +// `update_managed_agent` leaves `applied` undefined at `stamp_record_updated_at` +// — a compile error enforced by the `#[must_use] RecordFieldsApplied` token. +// `record_field_updates_persist_effort_to_disk` below proves the +// disk-persistence contract of `apply_record_field_updates` itself (calls it +// directly); it does not independently gate the production invocation. + +#[test] +fn non_local_set_is_rejected_and_record_not_mutated() { + let mut record = provider_record(false); + let err = apply_record_field_updates(&mut record, None, false, Some(Some("high".to_string()))) + .expect_err("non-local record must reject effort writes"); + assert!( + err.contains("remote effort is set at deploy time"), + "error must explain why non-local effort writes are rejected: {err}" + ); + // Column must not be touched — the rejection is before mutation. + assert_eq!( + record.effort_level, None, + "non-local record column must be unchanged after a rejected set" + ); +} + +#[test] +fn non_local_clear_is_rejected_and_record_not_mutated() { + // Clear (None inner value) is also rejected for non-local records — the + // outer Some signals presence; the inner None is the clear sentinel. + let mut record = provider_record(false); + let err = apply_record_field_updates(&mut record, None, false, Some(None)) + .expect_err("non-local record effort clear must also be rejected"); + assert!(err.contains("remote effort is set at deploy time")); + assert_eq!( + record.effort_level, None, + "non-local record column must be unchanged after a rejected clear" + ); +} + +#[test] +fn local_set_writes_column_and_sweeps_stale_alias() { + // `apply_record_field_updates` must write `effort_level` for a local record + // and strip any stale record-scope effort alias. Deleting the + // `apply_effort_update` call inside leaves `effort_level == None`. + let mut record = local_record(); + record + .env_vars + .insert("GOOSE_THINKING_EFFORT".to_string(), "low".to_string()); + + let _ = apply_record_field_updates(&mut record, None, false, Some(Some("high".to_string()))) + .expect("local record must accept effort set"); + + assert_eq!( + record.effort_level.as_deref(), + Some("high"), + "local set must write the canonical column" + ); + assert!( + !record.env_vars.contains_key("GOOSE_THINKING_EFFORT"), + "local set must sweep the stale record-native alias" + ); +} + +#[test] +fn local_clear_zeroes_column_and_sweeps_alias() { + let mut record = local_record(); + record.effort_level = Some("high".to_string()); + record + .env_vars + .insert("GOOSE_THINKING_EFFORT".to_string(), "high".to_string()); + + apply_record_field_updates(&mut record, None, false, Some(None)) + .expect("local record must accept effort clear"); + + assert_eq!( + record.effort_level, None, + "local clear must zero the canonical column" + ); + assert!( + !record.env_vars.contains_key("GOOSE_THINKING_EFFORT"), + "local clear must sweep the stale record-native alias" + ); +} + +#[test] +fn absent_effort_is_noop_for_any_backend() { + // A missing effortLevel field (the common case) must never be rejected and + // must never touch the column — this is the don't-touch path. + let mut local = local_record(); + apply_record_field_updates(&mut local, None, false, None) + .expect("absent effort must pass for local"); + assert_eq!( + local.effort_level, None, + "absent effort must not touch local column" + ); + + let mut provider = provider_record(true); + apply_record_field_updates(&mut provider, None, false, None) + .expect("absent effort must pass for provider"); + assert_eq!( + provider.effort_level, None, + "absent effort must not touch provider column" + ); +} + +#[test] +fn env_vars_applied_before_effort_ordering_invariant() { + // Order is load-bearing: env_vars BEFORE effort column write. A same-request + // env_vars map containing a stale alias (GOOSE_THINKING_EFFORT=low) alongside + // an explicit effort set (high) must end with the alias swept — not re-pinned. + // If env_vars were applied AFTER effort, the alias would survive. + let mut record = local_record(); + let mut env_vars = std::collections::BTreeMap::new(); + env_vars.insert("GOOSE_THINKING_EFFORT".to_string(), "low".to_string()); + + apply_record_field_updates( + &mut record, + Some(&env_vars), + false, + Some(Some("high".to_string())), + ) + .expect("ordering test must succeed for local record"); + + assert_eq!( + record.effort_level.as_deref(), + Some("high"), + "effort column must be set to the explicit value" + ); + assert!( + !record.env_vars.contains_key("GOOSE_THINKING_EFFORT"), + "alias in the same-request env_vars must be swept before the column is read at launch" + ); +} + +// ── Defensive direct-IPC contract ───────────────────────────────────────────── +// +// Non-blocking defensive coverage (Wes/Carl review): a contradictory request +// combining the ACP inherit sentinel in `env_vars` and a non-null effort_level +// must be deterministic — the effort write wins over the sentinel, and the +// sentinel is swept by the alias-removal step so it cannot shadow the column +// at launch time. The shipped renderer suppresses this combination, but the +// backend must not leave an ambiguous state. + +#[test] +fn effort_write_sweeps_acp_sentinel_in_env_vars() { + // A local record whose env_vars contain BUZZ_ACP_EFFORT_LEVEL (e.g. manually + // set by a user) plus a concurrent explicit effort_level write. The column + // must be set to the explicit value AND the sentinel must be removed. + let mut record = local_record(); + record.env_vars.insert( + "BUZZ_ACP_EFFORT_LEVEL".to_string(), + "old-sentinel".to_string(), + ); + apply_record_field_updates(&mut record, None, false, Some(Some("high".to_string()))) + .expect("local record must accept effort set"); + assert_eq!( + record.effort_level.as_deref(), + Some("high"), + "effort write must set the column" + ); + assert!( + !record.env_vars.contains_key("BUZZ_ACP_EFFORT_LEVEL"), + "ACP sentinel in env_vars must be swept by the alias-removal step" + ); +} + +#[test] +fn effort_clear_sweeps_acp_sentinel_in_env_vars() { + // A concurrent clear (None inner value) plus a pre-existing ACP sentinel. + // After the clear the column is None and the sentinel is gone — no ambiguity. + let mut record = local_record(); + record.effort_level = Some("high".to_string()); + record.env_vars.insert( + "BUZZ_ACP_EFFORT_LEVEL".to_string(), + "old-sentinel".to_string(), + ); + apply_record_field_updates(&mut record, None, false, Some(None)) + .expect("local record must accept effort clear"); + assert_eq!( + record.effort_level, None, + "effort clear must zero the column" + ); + assert!( + !record.env_vars.contains_key("BUZZ_ACP_EFFORT_LEVEL"), + "ACP sentinel in env_vars must be swept on clear" + ); +} + +// ── Helper disk-persistence contract ───────────────────────────────────────── +// +// This test drives the production helper sequence directly in its own body: +// load_managed_agents → apply_record_field_updates → stamp_record_updated_at +// → save_managed_agents → load-from-disk. +// +// Mutation proofs (scoped to this test body): +// - Removing `apply_record_field_updates` from this test body leaves +// `applied` undefined at `stamp_record_updated_at` — a compile error. +// - Removing the function call and stubbing the token manually leaves +// `effort_level` unchanged on disk — assertion fails (expected +// Some("high"), got None). +// +// Outer-seam gate: the compile error that prevents skipping +// `apply_record_field_updates` inside `update_managed_agent` is described in +// the outer-seam comment above (undefined `applied` token at the +// `stamp_record_updated_at` site). This test proves only the helper's own +// disk-roundtrip contract; it does not independently gate the production +// invocation. + +#[cfg(not(target_os = "windows"))] +#[test] +fn record_field_updates_persist_effort_to_disk() { + use crate::app_state::build_app_state; + use crate::managed_agents::{load_managed_agents, save_managed_agents}; + + // A single crate-wide process-env lock covers PATH, HOME, XDG_DATA_HOME, + // and all effort env keys — `lock_path_mutex` and `lock_env_mutex` both + // delegate to the same `PROCESS_ENV_MUTEX` static. + let _env_guard = crate::managed_agents::lock_path_mutex(); + let temp = tempfile::tempdir().unwrap(); + let home = temp.path().join("home"); + std::fs::create_dir_all(&home).unwrap(); + + // RAII guards restore HOME and XDG_DATA_HOME on Drop (even on panic). + // Uses OsString so a pre-existing non-Unicode value is restored exactly. + struct EnvVarGuard { + key: String, + prior: Option, + } + impl EnvVarGuard { + fn set(key: &str, value: &std::path::Path) -> Self { + let prior = std::env::var_os(key); + #[allow(deprecated)] + // SAFETY: caller holds the crate-wide process-env lock. + unsafe { + std::env::set_var(key, value) + }; + Self { + key: key.to_string(), + prior, + } + } + } + impl Drop for EnvVarGuard { + fn drop(&mut self) { + #[allow(deprecated)] + // SAFETY: caller holds the crate-wide process-env lock. + unsafe { + match &self.prior { + Some(v) => std::env::set_var(&self.key, v), + None => std::env::remove_var(&self.key), + } + } + } + } + + let _home_guard = EnvVarGuard::set("HOME", &home); + let _xdg_guard = EnvVarGuard::set("XDG_DATA_HOME", &home); + + let app = tauri::test::mock_builder() + .manage(build_app_state()) + .build(tauri::test::mock_context(tauri::test::noop_assets())) + .expect("mock app builds headless"); + + // Seed a local record with no effort set. + let seed: crate::managed_agents::ManagedAgentRecord = + serde_json::from_value(serde_json::json!({ + "pubkey": "test-effort-agent", + "name": "Effort Test Agent", + "relay_url": "", "acp_command": "", "agent_command": "", + "agent_args": [], "mcp_command": "", "turn_timeout_seconds": 0, + "system_prompt": null, "created_at": "2026-01-01T00:00:00Z", + "updated_at": "2026-01-01T00:00:00Z", "last_started_at": null, + "last_stopped_at": null, "last_exit_code": null, "last_error": null + })) + .unwrap(); + save_managed_agents(app.handle(), &[seed]).unwrap(); + + // Drive the production seam: load → apply_record_field_updates → + // stamp_record_updated_at → save. This is the exact sequence that + // `update_managed_agent` executes inside its locked transaction. + let mut records = load_managed_agents(app.handle()).unwrap(); + let record = records + .iter_mut() + .find(|r| r.pubkey == "test-effort-agent") + .expect("seeded record must load"); + let applied = apply_record_field_updates(record, None, false, Some(Some("high".to_string()))) + .expect("local record must accept effort set"); + stamp_record_updated_at(record, applied); + save_managed_agents(app.handle(), &records).unwrap(); + + // Verify effort landed on disk. + let saved = load_managed_agents(app.handle()).unwrap(); + let saved_record = saved + .iter() + .find(|r| r.pubkey == "test-effort-agent") + .expect("agent must persist after update"); + assert_eq!( + saved_record.effort_level.as_deref(), + Some("high"), + "apply_record_field_updates + stamp_record_updated_at must write effort_level to disk" + ); + // _home_guard and _xdg_guard restore HOME and XDG_DATA_HOME via Drop. +} diff --git a/desktop/src-tauri/src/commands/agents_deploy.rs b/desktop/src-tauri/src/commands/agents_deploy.rs index 359a35abcf..de8ca8cc78 100644 --- a/desktop/src-tauri/src/commands/agents_deploy.rs +++ b/desktop/src-tauri/src/commands/agents_deploy.rs @@ -98,13 +98,13 @@ fn build_launch_block_for_policy( }; policy_env.insert(model_key.into(), value.to_string()); } - // I-4: remote parity for persisted startup effort. Mirrors the local spawn - // path in runtime.rs. The harness reads BUZZ_ACP_EFFORT_LEVEL into - // PoolStartup.startup_effort and applies it at first session creation via - // resolve_startup_effort(). - if let Some(ref value) = record.effort_level { - policy_env.insert("BUZZ_ACP_EFFORT_LEVEL".into(), value.clone()); - } + // Startup effort needs no remote-specific handling: the harness-agnostic + // effort projection already ran inside `resolve_effective_harness_descriptor`, + // so `descriptor.env` (→ `launch.env`, tier 2) carries exactly one effort key + // holding the effective value, with every foreign/legacy/transport effort key + // stripped. Tier 2 later-wins over `policy_env` (tier 1) and no authoritative + // tier-3 key collides with an effort key, so the projected value reaches the + // remote pod verbatim — identical authority to the local spawn. if let Some(value) = record.idle_timeout_seconds { policy_env.insert("BUZZ_ACP_IDLE_TIMEOUT".into(), value.to_string()); } @@ -121,14 +121,6 @@ fn build_launch_block_for_policy( policy_env.insert("BUZZ_ACP_TEAM_INSTRUCTIONS".into(), value); } - // B5 remote parity: when a canonical effort_level is persisted, strip - // BUZZ_ACP_EFFORT_LEVEL from launch.env so it cannot shadow the canonical - // value in policy_env (tier 1). In the k8s three-tier model tier 2 - // (launch.env) overwrites tier 1 (policy_env) — later-wins — so the key - // must be absent from tier 2 whenever a canonical value is present. - // When effort_level is None there is no canonical to protect, so user - // env passthrough stands (env may legitimately seed startup effort). - // // B2 remote parity: mirror the local A1 model authority. For a Claude // launch, ALWAYS strip BOTH BUZZ_ACP_MODEL and ANTHROPIC_MODEL from // launch.env — the resolved canonical model rides policy_env.ANTHROPIC_MODEL @@ -138,10 +130,13 @@ fn build_launch_block_for_policy( // canonical model. When no canonical model is present, neither key is in // policy_env, so stripping them keeps the remote process free of both — // matching local, where `apply_claude_model_env(None)` removes both. + // + // Effort keys need no stripping here: the projection already reduced + // `descriptor.env` to exactly one effort key holding the effective value, + // so launch.env carries the authority directly (see the effort note above). let is_claude = runtime.map(|r| r.id == "claude").unwrap_or(false); let strip_key = |k: &str| { k.eq_ignore_ascii_case(crate::managed_agents::ACP_SESSION_POLICY_ENV_VAR) - || (record.effort_level.is_some() && k.eq_ignore_ascii_case("BUZZ_ACP_EFFORT_LEVEL")) || (is_claude && (k.eq_ignore_ascii_case("BUZZ_ACP_MODEL") || k.eq_ignore_ascii_case("ANTHROPIC_MODEL"))) @@ -522,19 +517,27 @@ mod tests { } #[test] - fn launch_block_claude_runtime_injects_effort_level_when_set() { - // I-4: remote parity — record.effort_level → BUZZ_ACP_EFFORT_LEVEL in policy_env. - let mut record = record(); - record.effort_level = Some("high".to_string()); + fn launch_block_claude_runtime_carries_projected_effort_in_launch_env() { + // Under the harness-agnostic projection, effort no longer rides + // policy_env: `resolve_effective_harness_descriptor` reduces + // `descriptor.env` to exactly one effort key (for a keyless claude + // runtime, the ACP sentinel) holding the effective value, and + // build_launch_block passes that env through to launch.env verbatim. + let record = record(); let descriptor = EffectiveHarnessDescriptor { command: "claude".into(), args: vec![], - env: BTreeMap::new(), + // The single projected effort key the descriptor resolver emits. + env: BTreeMap::from([("BUZZ_ACP_EFFORT_LEVEL".to_string(), "high".to_string())]), }; let launch = build_launch_block(&record, &descriptor, &[], None, None, "owner-hex"); assert_eq!( - launch["policy_env"]["BUZZ_ACP_EFFORT_LEVEL"], "high", - "claude remote must receive BUZZ_ACP_EFFORT_LEVEL when effort_level is set" + launch["env"]["BUZZ_ACP_EFFORT_LEVEL"], "high", + "the projected effort key must survive into launch.env" + ); + assert!( + launch["policy_env"]["BUZZ_ACP_EFFORT_LEVEL"].is_null(), + "effort is not a policy_env value under the projection design" ); } @@ -561,26 +564,35 @@ mod tests { /// authoritative. #[test] fn launch_block_canonical_effort_strips_user_env_collision() { + // Remote parity for the authority collision: the canonical column and a + // conflicting user `BUZZ_ACP_EFFORT_LEVEL` both present. The projection + // (run inside `resolve_effective_harness_descriptor`) resolves it — + // canonical `high` wins over the user `low` transport sentinel — and + // build_launch_block carries exactly that one value into launch.env, + // identical to the local spawn path. let mut record = record(); + record.runtime = Some("claude".into()); record.effort_level = Some("high".to_string()); - let descriptor = EffectiveHarnessDescriptor { - command: "claude".into(), - args: vec![], - // User-supplied conflicting value in descriptor.env. - env: BTreeMap::from([("BUZZ_ACP_EFFORT_LEVEL".to_string(), "low".to_string())]), - }; + record + .env_vars + .insert("BUZZ_ACP_EFFORT_LEVEL".into(), "low".into()); + let descriptor = crate::managed_agents::resolve_effective_harness_descriptor( + &record, + &[], + &Default::default(), + ) + .expect("claude descriptor resolves"); let launch = build_launch_block(&record, &descriptor, &[], None, None, "owner-hex"); - // Canonical must be in policy_env (tier 1). + // The projected canonical authority is the single effort value carried. assert_eq!( - launch["policy_env"]["BUZZ_ACP_EFFORT_LEVEL"], "high", - "canonical effort must be in policy_env when record.effort_level is Some" + launch["env"]["BUZZ_ACP_EFFORT_LEVEL"], "high", + "canonical effort must win the collision and reach launch.env" ); - // Conflicting user value must be absent from launch.env (tier 2) so it - // cannot shadow the canonical tier-1 value in build_env. + // Effort is not a policy_env value under the projection design. assert!( - launch["env"]["BUZZ_ACP_EFFORT_LEVEL"].is_null(), - "user BUZZ_ACP_EFFORT_LEVEL must be stripped from launch.env when canonical is present" + launch["policy_env"]["BUZZ_ACP_EFFORT_LEVEL"].is_null(), + "effort is carried in launch.env, never policy_env" ); } diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index fe2bba5024..0e4832a702 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -715,7 +715,6 @@ pub fn run() { get_baked_build_env_keys, get_baked_build_env, put_agent_session_config, - persist_agent_effort_level, get_global_agent_config, set_global_agent_config, mesh_start_node, diff --git a/desktop/src-tauri/src/managed_agents/claude_config/mod.rs b/desktop/src-tauri/src/managed_agents/claude_config/mod.rs index 647ea56209..0871544dbc 100644 --- a/desktop/src-tauri/src/managed_agents/claude_config/mod.rs +++ b/desktop/src-tauri/src/managed_agents/claude_config/mod.rs @@ -4,15 +4,10 @@ //! local Claude Code agents. `BUZZ_ACP_MODEL` is removed from the spawned //! env so the harness never sees two model authorities simultaneously. //! -//! B5 contract: `BUZZ_ACP_EFFORT_LEVEL` is the canonical persisted startup -//! effort authority for all local agents. Written after `descriptor.env` so -//! user-supplied entries cannot shadow a persisted canonical value. - -/// The spawn-time env var carrying startup effort. Shared by the spawn -/// application ([`apply_effort_env`]) and the snapshot projection -/// (`spawn_snapshot::effective_effort`) so the value the harness receives and -/// the value the restart badge compares are named from one place. -pub const EFFORT_LEVEL_ENV_VAR: &str = "BUZZ_ACP_EFFORT_LEVEL"; +//! Startup effort is no longer applied here: the harness-agnostic effort +//! projection (`config_bridge::effort`) runs inside the descriptor resolver, so +//! `descriptor.env` already carries exactly one effort key. See that module for +//! the single-authority contract, including the ACP-startup key constant. /// Apply the A1 model authority: inject `ANTHROPIC_MODEL` from `effective_model` /// (or remove it if `None`) and strip `BUZZ_ACP_MODEL` from the spawned env. @@ -33,21 +28,6 @@ pub fn apply_claude_model_env(command: &mut std::process::Command, effective_mod } } -/// Apply the B5 effort authority: inject `BUZZ_ACP_EFFORT_LEVEL` from -/// `effort_level` (or leave it untouched if `None`). -/// -/// Must be called after `descriptor.env` is written so the canonical persisted -/// value wins over any user-supplied `BUZZ_ACP_EFFORT_LEVEL` entry. When -/// `effort_level` is `None` there is no canonical value to assert; the command -/// env is left untouched so a user-supplied value from `descriptor.env` -/// legitimately seeds startup effort. -pub fn apply_effort_env(command: &mut std::process::Command, effort_level: Option<&str>) { - if let Some(e) = effort_level { - command.env(EFFORT_LEVEL_ENV_VAR, e); - } - // None: no canonical value — leave whatever descriptor.env wrote intact. -} - #[cfg(test)] #[path = "tests.rs"] mod tests; diff --git a/desktop/src-tauri/src/managed_agents/claude_config/tests.rs b/desktop/src-tauri/src/managed_agents/claude_config/tests.rs index f6f0f90cb2..0e596bc72b 100644 --- a/desktop/src-tauri/src/managed_agents/claude_config/tests.rs +++ b/desktop/src-tauri/src/managed_agents/claude_config/tests.rs @@ -1,4 +1,4 @@ -use super::{apply_claude_model_env, apply_effort_env}; +use super::apply_claude_model_env; /// A1: BUZZ_ACP_MODEL must NOT be present in the spawned-child env after /// `apply_claude_model_env`, even if it was set before (dual-authority defect). @@ -54,74 +54,10 @@ fn a1_anthropic_model_removed_when_no_effective_model() { ); } -// ── B5 effort-authority contract tests ────────────────────────────────────── +// ── B5 effort-authority contract ───────────────────────────────────────────── // -// These tests verify that `apply_effort_env`, called after `descriptor.env`, -// makes the canonical persisted effort win over any user-supplied value. - -/// B5 (local): canonical effort wins when user env supplies a conflicting value. -/// Simulates the defect scenario: descriptor.env wrote BUZZ_ACP_EFFORT_LEVEL=low, -/// then apply_effort_env is called with the canonical "high". The canonical value -/// must be what survives in the spawned-child env. -#[test] -fn b5_canonical_effort_wins_over_user_env_collision() { - let mut cmd = std::process::Command::new("true"); - // Simulate descriptor.env writing a user-supplied value (the pre-fix - // ordering: effort written before the loop, then loop overwrote it, or - // equivalently: effort written post-loop but with user value also post-loop). - cmd.env("BUZZ_ACP_EFFORT_LEVEL", "low"); - - // Post-loop canonical application — the fix. - apply_effort_env(&mut cmd, Some("high")); - - let env_map: std::collections::HashMap<_, _> = cmd.get_envs().collect(); - let effort = env_map.get(std::ffi::OsStr::new("BUZZ_ACP_EFFORT_LEVEL")); - assert!(effort.is_some(), "BUZZ_ACP_EFFORT_LEVEL must be present"); - assert_eq!( - effort.unwrap().unwrap_or_default(), - "high", - "canonical effort must win over the user-supplied 'low' — B5 authority ordering" - ); -} - -/// B5 (local): when no canonical effort is persisted (effort_level is None), -/// user env passthrough is preserved — the descriptor.env entry seeds startup effort. -/// Simulates: descriptor.env wrote BUZZ_ACP_EFFORT_LEVEL=low (already in command), -/// then apply_effort_env(None) is called — user value must survive. -#[test] -fn b5_user_effort_env_survives_when_no_canonical_value() { - let mut cmd = std::process::Command::new("true"); - // Simulate descriptor.env loop having written a user-supplied value first. - cmd.env("BUZZ_ACP_EFFORT_LEVEL", "low"); - - // No canonical value — apply_effort_env(None) is a no-op so the user - // value already written by the descriptor.env loop survives intact. - apply_effort_env(&mut cmd, None); - - let env_map: std::collections::HashMap<_, _> = cmd.get_envs().collect(); - let effort = env_map.get(std::ffi::OsStr::new("BUZZ_ACP_EFFORT_LEVEL")); - assert!(effort.is_some(), "BUZZ_ACP_EFFORT_LEVEL must be present"); - assert_eq!( - effort.unwrap().unwrap_or_default(), - "low", - "user-supplied effort must survive when no canonical value is persisted" - ); -} - -/// B5 (local): canonical effort is present in the spawned env even when user -/// env did NOT supply a conflicting value (basic injection contract). -#[test] -fn b5_canonical_effort_injected_when_no_user_collision() { - let mut cmd = std::process::Command::new("true"); - // No user-supplied BUZZ_ACP_EFFORT_LEVEL in descriptor.env. - apply_effort_env(&mut cmd, Some("medium")); - - let env_map: std::collections::HashMap<_, _> = cmd.get_envs().collect(); - let effort = env_map.get(std::ffi::OsStr::new("BUZZ_ACP_EFFORT_LEVEL")); - assert!(effort.is_some(), "BUZZ_ACP_EFFORT_LEVEL must be present"); - assert_eq!( - effort.unwrap().unwrap_or_default(), - "medium", - "canonical effort must be injected when no collision" - ); -} +// Startup-effort application moved out of this module into the single +// harness-agnostic projection (`config_bridge::effort`). Its authority, +// collision, and single-key contract is exercised by +// `config_bridge::effort::tests`; there is no longer a Claude-local effort +// helper to test here. diff --git a/desktop/src-tauri/src/managed_agents/config_bridge/effort.rs b/desktop/src-tauri/src/managed_agents/config_bridge/effort.rs new file mode 100644 index 0000000000..e06fe06216 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/config_bridge/effort.rs @@ -0,0 +1,506 @@ +//! The single harness-agnostic effort authority (plan-of-record, PR #4625). +//! +//! ## One projection, one destination key, one snapshot leaf +//! +//! [`effort_launch_projection`] resolves the effective startup effort a spawn +//! would apply, over the canonical persisted column (`record.effort_level`) AND +//! the sanitized per-tier env inputs, in the CLEAR authority order: +//! +//! ```text +//! record native(valid) > canonical column(valid) > record legacy(valid) +//! > persona(native, then legacy) > global(native) > definition(native) +//! > baked(native) +//! ``` +//! +//! (The reader adds the live-ACP tier between column and persona and the config +//! file tier at the bottom; the launch projection has neither — a spawn reads +//! neither a running session nor the on-disk harness file.) +//! +//! The **tier-reading** native key is the runtime's real `thinking_env_var` +//! (`None` for Claude/Codex — those have no native key, so the column is the +//! sole authority and a user-supplied `BUZZ_ACP_EFFORT_LEVEL` is transport, not +//! a tier). The **emission** key ([`EffortLaunch::key`]) is +//! `thinking_env_var.unwrap_or(BUZZ_ACP_EFFORT_LEVEL)`: Goose emits +//! `GOOSE_THINKING_EFFORT`, buzz-agent emits `BUZZ_AGENT_THINKING_EFFORT`, +//! Claude/Codex/keyless-ACP and any unknown/custom runtime emit the retained +//! ACP-startup sentinel `BUZZ_ACP_EFFORT_LEVEL`. +//! +//! [`EffortLaunch::suppress`] lists every known native/legacy effort key plus +//! the sentinel; every consumer strips them all first, then emits at most the +//! one `key`. This is what guarantees a launched process, a remote payload, and +//! a restart snapshot can never carry two effort authorities. + +use std::collections::BTreeMap; + +use super::LEGACY_THINKING_EFFORT_KEY; +use crate::managed_agents::custom_harnesses::HarnessDefinition; +use crate::managed_agents::discovery::{EffortNormalization, KnownAcpRuntime}; +use crate::managed_agents::types::{AgentDefinition, ManagedAgentRecord}; + +/// The retained ACP-startup transport key. Claude, Codex, keyless ACP adapters, +/// and any unknown/custom runtime route the effective effort through this key +/// (the harness reads it into `PoolStartup.startup_effort`). It is *transport*, +/// never a value-authority tier: a user-supplied entry is suppressed and +/// overwritten by the projected effective value. +pub(crate) const ACP_STARTUP_EFFORT_KEY: &str = "BUZZ_ACP_EFFORT_LEVEL"; + +/// The resolved launch effort for one runtime: the single fact every spawn +/// path (local, remote, snapshot) consumes. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct EffortLaunch { + /// The final effective effort value, normalized for contract runtimes and + /// raw for contract-less ones, resolved over ALL tiers (column + env). + /// `None` when no tier supplies a value the destination can express. + pub value: Option, + /// The destination env key the value is emitted under. + pub key: &'static str, + /// Every effort key to strip from the launch env before emitting `key`. + /// Always includes the sentinel and all known native/legacy effort keys, so + /// no foreign or transport effort key can shadow the projected authority. + pub suppress: Vec<&'static str>, + /// When no tier resolved a `value`, preserve a value the launch env already + /// carries under `key` (collapsing every case variant to the canonical + /// spelling). Set only for unknown/custom runtimes, where the ACP sentinel + /// is user pass-through transport that must survive a spawn — not a foreign + /// key to drop. Known runtimes leave it `false`: a bare destination-key + /// value with no resolved authority is invalid/foreign and is dropped. + pub preserve_passthrough: bool, +} + +impl EffortLaunch { + /// Apply the projection to a launch env map: strip every `suppress` key, + /// then emit `key = value` when a value is present. After this call the map + /// holds at most one effort key (`key`), carrying the effective value. + /// + /// Suppression is ASCII-case-insensitive: Windows `Command` case-folds env + /// names, so a hand-set `goose_thinking_effort` would otherwise evade an + /// exact-case strip and shadow the projected authority. + /// + /// When `preserve_passthrough` is set and no tier resolved a value, a value + /// already present under `key` (in any case) is carried forward and + /// re-emitted canonically. Multiple case spellings can survive the + /// case-sensitive layer merge (e.g. a lower-tier `BUZZ_ACP_EFFORT_LEVEL` + /// plus a higher-tier `buzz_acp_effort_level`); the carry selects the LAST + /// case-insensitive match in `BTreeMap` iteration order, which is exactly + /// the value Rust's Windows `Command` writer produces — it sets each spelling + /// in iteration order into a case-folded env map, so the last set wins. This + /// keeps an unknown/custom runtime's hand-set sentinel alive, preserves the + /// value the child would actually receive, and guarantees one canonical + /// spelling downstream. + pub(crate) fn apply(&self, env: &mut BTreeMap) { + let carried = (self.value.is_none() && self.preserve_passthrough) + .then(|| { + env.iter() + .rev() + .find(|(k, _)| k.eq_ignore_ascii_case(self.key)) + .map(|(_, v)| v.clone()) + }) + .flatten(); + env.retain(|k, _| { + !self + .suppress + .iter() + .any(|suppressed| k.eq_ignore_ascii_case(suppressed)) + }); + if let Some(v) = self.value.as_ref().or(carried.as_ref()) { + env.insert(self.key.to_string(), v.clone()); + } + } +} + +/// Look up `key` in `map` case-insensitively (ASCII), selecting the LAST +/// case-insensitive match in `BTreeMap` iteration order. Effort key resolution +/// must match Windows `Command` env semantics: `Command` writes each spelling +/// in iteration order into a case-folded env map, so the last-set spelling wins +/// and is the value the child actually receives. Preferring an exact match +/// instead would pick a different case variant than the child gets — e.g. +/// `GOOSE_THINKING_EFFORT=low` plus `goose_thinking_effort=high` would resolve +/// to `low` while the child runs `high`. This mirrors `EffortLaunch::apply`'s +/// `.rev().find` carry so the tier reader, the passthrough carry, and the child +/// all agree on one value. +pub(crate) fn get_ci<'a>(map: &'a BTreeMap, key: &str) -> Option<&'a String> { + map.iter() + .rev() + .find(|(k, _)| k.eq_ignore_ascii_case(key)) + .map(|(_, v)| v) +} + +/// Resolve the single harness-agnostic effort authority and apply it to a fully +/// layered launch `env`: strip every known/legacy/transport effort key, then +/// emit exactly the one destination key holding the effective value. Called by +/// the descriptor resolver AFTER the full layer stack, so the launch env, the +/// remote deploy payload, and the restart snapshot all carry one effort key and +/// one value — no double authority, no foreign key, no launch/badge disagreement. +#[allow(clippy::too_many_arguments)] +pub(crate) fn apply_launch_effort( + env: &mut BTreeMap, + record: &ManagedAgentRecord, + runtime: Option<&KnownAcpRuntime>, + personas: &[AgentDefinition], + global_env: &BTreeMap, + harness_def: Option<&HarnessDefinition>, + baked_env: &BTreeMap, +) { + effort_launch_projection( + record, + runtime, + personas, + record.persona_id.as_deref(), + global_env, + harness_def, + baked_env, + ) + .apply(env); +} + +/// Resolve one effort tier's value, applying within-tier legacy aliasing and +/// normalization. Returns the canonical (or raw, contract-less) value, or +/// `None` when no usable candidate exists. +/// +/// Lookup (per tier, independent of other tiers): +/// 1. Native key — normalized; invalid → skip as absent. +/// 2. Legacy key (`BUZZ_AGENT_THINKING_EFFORT`) — only when the native key +/// differs from it AND `allow_legacy_alias` is set AND the value +/// normalizes. Invalid legacy is skipped so the next tier can supply one. +pub(crate) fn effort_tier_alias( + map: &BTreeMap, + native_key: &str, + norm: impl Fn(&str) -> Option, + allow_legacy_alias: bool, +) -> Option { + if let Some(raw) = get_ci(map, native_key) { + if let Some(canonical) = norm(raw) { + return Some(canonical); + } + } + if allow_legacy_alias && native_key != LEGACY_THINKING_EFFORT_KEY { + if let Some(raw) = get_ci(map, LEGACY_THINKING_EFFORT_KEY) { + if let Some(canonical) = norm(raw) { + return Some(canonical); + } + } + } + None +} + +/// Normalize/validate an effort candidate for a runtime's destination +/// vocabulary. The single value gate shared by the launch projection and the +/// reader, so the panel and the next spawn never disagree on a value's validity. +/// +/// - `contract` present (Goose): canonicalize through the alias table; invalid +/// → `None` (skip as absent). +/// - `contract` absent but `accepted` present (buzz-agent): validation-only — +/// accept a value case-insensitively iff the destination parser would +/// (`parse_thinking_effort`), emit it lowercased; a foreign canonical (e.g. +/// Goose `off`) is rejected so it is never emitted as +/// `BUZZ_AGENT_THINKING_EFFORT=off`, which crashes the child at config init. +/// - both absent (Claude/Codex, unknown/custom): raw passthrough — the value +/// rides `BUZZ_ACP_EFFORT_LEVEL` to an adapter that accepts any string. +pub(crate) fn normalize_effort( + contract: Option<&EffortNormalization>, + accepted: Option<&[&str]>, + raw: &str, +) -> Option { + match contract { + Some(c) => c.normalize_str(raw), + None => match accepted { + Some(values) => { + let lower = raw.trim().to_ascii_lowercase(); + values.iter().any(|v| *v == lower).then_some(lower) + } + None => Some(raw.to_string()), + }, + } +} + +/// The destination env key the effective effort is emitted under for `runtime`: +/// the runtime's native `thinking_env_var`, else the ACP-startup sentinel +/// (Claude, Codex, keyless ACP adapters, and unknown/custom runtimes). +pub(crate) fn effort_dest_key(runtime: Option<&KnownAcpRuntime>) -> &'static str { + runtime + .and_then(|r| r.thinking_env_var) + .unwrap_or(ACP_STARTUP_EFFORT_KEY) +} + +/// Every effort key to strip before emitting the single destination key: all +/// known native effort keys, the legacy alias, and the ACP-startup sentinel. +/// Stripping the full set guarantees no foreign or transport effort key can +/// shadow the projected authority. +pub(crate) fn effort_suppress_keys() -> Vec<&'static str> { + let mut keys: Vec<&'static str> = super::all_known_effort_keys().collect(); + if !keys.contains(&ACP_STARTUP_EFFORT_KEY) { + keys.push(ACP_STARTUP_EFFORT_KEY); + } + if !keys.contains(&LEGACY_THINKING_EFFORT_KEY) { + keys.push(LEGACY_THINKING_EFFORT_KEY); + } + keys +} + +/// Strip every known effort key from a [`std::process::Command`] before the +/// descriptor overlay is written. +/// +/// Only used in tests to verify tombstone assertions on individual keys. +/// Production stripping runs inside `apply_effort_launch_to_command` +/// (the loop over `launch.suppress`) which is exercised by the +/// production-sequence tests. +#[cfg(test)] +pub(crate) fn strip_effort_keys_from_command(cmd: &mut std::process::Command) { + for key in effort_suppress_keys() { + cmd.env_remove(key); + // Belt-and-suspenders for Unix inherited env with non-canonical casing + // (e.g. a shell export of `goose_thinking_effort`). Our own cmd.env() + // calls always use UPPER_SNAKE_CASE; only ambient inherited keys can + // arrive in non-standard case on Unix. + let lower = key.to_ascii_lowercase(); + if lower != key { + cmd.env_remove(&lower); + } + } +} + +/// Strip effort keys and emit the projected effort value to a +/// [`std::process::Command`]. +/// +/// This is the production command-boundary seam: call after +/// `build_buzz_agent_provider_defaults` (which writes raw baked env) and +/// before the `descriptor.env` loop (which overlays the projected key). +/// Extracting both steps into one call lets tests exercise the full +/// baked-write → strip → emit sequence and inspect the child's effective +/// environment, making the test fail if either step is removed or misordered +/// in production. +/// +/// Strip policy follows `launch.suppress`: for known runtimes that is the full +/// effort vocabulary; for unknown/custom runtimes it is only the ACP sentinel, +/// leaving foreign effort keys (e.g. a wrapper's own `GOOSE_THINKING_EFFORT`) +/// untouched. Each key is stripped in canonical and lowercase form so ambient +/// inherited env with non-canonical casing is swept on Unix. +/// +/// When `launch.preserve_passthrough` is set and `launch.value` is `None` +/// (unknown runtime, no authoritative column), the suppress set is skipped +/// entirely: the inherited process env carries the user's hand-set sentinel, +/// and stripping it here without a re-emit would silently drop it. Known +/// runtimes always have a resolved `value` or do not set `preserve_passthrough`. +pub(crate) fn apply_effort_launch_to_command( + cmd: &mut std::process::Command, + launch: &EffortLaunch, +) { + // For unknown/custom runtimes with no resolved value the suppress set is + // only the ACP sentinel, and stripping it without re-emitting would destroy + // the user's ambient pass-through config. Skip the strip entirely and let + // the inherited env carry it through unchanged. + // MUTATION: removing this guard strips the sentinel and breaks + // `production_sequence_custom_inherited_acp_sentinel_survives`. + if launch.preserve_passthrough && launch.value.is_none() { + return; + } + for key in &launch.suppress { + cmd.env_remove(key); + let lower = key.to_ascii_lowercase(); + if lower.as_str() != *key { + cmd.env_remove(&lower); + } + } + if let Some(ref value) = launch.value { + cmd.env(launch.key, value); + } +} + +/// The effort keys the restart snapshot must strip from its captured launch env +/// so effort keeps exactly ONE representation (`effort_level`), mirroring what +/// [`effort_launch_projection`] actually suppressed for `runtime`: +/// +/// - **known runtime** — the full suppress set. The projection already swept +/// every effort key to the single destination key, so this removes only that +/// destination key (a no-op on the already-swept siblings). +/// - **unknown/custom runtime** — only the ACP-startup sentinel. The projection +/// suppresses just the sentinel here (reconciling every case variant to the +/// canonical spelling — external review, Carl P2), leaving every other +/// effort-looking key (e.g. a hand-rolled `GOOSE_THINKING_EFFORT`) untouched +/// as ordinary env. Those must remain in `env` so an edit to them diffs the +/// snapshot normally; only the sentinel — the key the projection emits and +/// `effective_effort` reads into `effort_level` — is removed. +pub(crate) fn snapshot_suppress_keys(runtime: Option<&KnownAcpRuntime>) -> Vec<&'static str> { + if runtime.is_some() { + effort_suppress_keys() + } else { + vec![effort_dest_key(runtime)] + } +} + +/// Build the single effective-effort projection for a launch. +/// +/// `global_env`, `persona_id`+`personas`, `harness_def`, and `baked_env` supply +/// the same per-tier inputs the layered spawn env is built from; the projection +/// re-reads them so an invalid high-tier value skips as absent and a lower tier +/// can win (which a merged last-wins env map cannot express). +pub(crate) fn effort_launch_projection( + record: &ManagedAgentRecord, + runtime: Option<&KnownAcpRuntime>, + personas: &[AgentDefinition], + persona_id: Option<&str>, + global_env: &BTreeMap, + harness_def: Option<&HarnessDefinition>, + baked_env: &BTreeMap, +) -> EffortLaunch { + let key = effort_dest_key(runtime); + + // Suppress the full effort vocabulary for KNOWN runtimes. For an + // unknown/custom runtime (external review #2) we keep every foreign + // effort-looking key as pass-through — a hand-rolled `GOOSE_THINKING_EFFORT` + // on a custom Goose wrapper must reach the child untouched — EXCEPT our own + // ACP-startup sentinel, which we always reconcile to a single canonical + // spelling (external review, Carl P2): the projection emits the sentinel, so + // a user-set case variant (e.g. `buzz_acp_effort_level`) is never intentional + // config, and leaving one to shadow the emitted `BUZZ_ACP_EFFORT_LEVEL` on + // Windows (where `Command` case-folds env names) would hand the child a + // different value than the snapshot reads. Stripping the sentinel here and + // re-emitting canonically guarantees at most ONE sentinel spelling downstream, + // so the child, the restart snapshot, and the badge cannot disagree on case. + let suppress = if runtime.is_some() { + effort_suppress_keys() + } else { + vec![ACP_STARTUP_EFFORT_KEY] + }; + // When no tier resolves a value, an unknown runtime still preserves a + // hand-set sentinel the user routed to the child (the retained pass-through + // from external review #2) — carried forward and re-emitted canonically by + // `apply`. Known runtimes never preserve a bare dest-key value: it is either + // the projection's own emission or a foreign key, both handled by `value`. + let preserve_passthrough = runtime.is_none(); + + // Value gate: Goose canonicalizes through its alias contract; buzz-agent + // validates against its accepted set (invalid → skip, so a foreign + // canonical like Goose `off` is never emitted where the destination parser + // rejects it); Claude/Codex and unknown/custom pass raw over the sentinel. + let contract = runtime.and_then(|r| r.effort_normalization); + let accepted = runtime.and_then(|r| r.effort_accepted_values); + let norm = |raw: &str| -> Option { normalize_effort(contract, accepted, raw) }; + + // Tier-reading native key: the runtime's REAL native key. `None` (Claude, + // Codex, unknown/custom) means there are no env-tier authorities — the + // sentinel in user env is transport only — so the column is the sole source. + let native_key = runtime.and_then(|r| r.thinking_env_var); + + let value = resolve_effective_effort( + record, + native_key, + &norm, + personas, + persona_id, + global_env, + harness_def, + baked_env, + ); + + EffortLaunch { + value, + key, + suppress, + preserve_passthrough, + } +} + +/// Resolve the effective effort value in CLEAR authority order (launch tiers). +#[allow(clippy::too_many_arguments)] +fn resolve_effective_effort( + record: &ManagedAgentRecord, + native_key: Option<&str>, + norm: &impl Fn(&str) -> Option, + personas: &[AgentDefinition], + persona_id: Option<&str>, + global_env: &BTreeMap, + harness_def: Option<&HarnessDefinition>, + baked_env: &BTreeMap, +) -> Option { + use crate::managed_agents::env_vars::{is_reserved_env_key, live_persona_env, merged_user_env}; + + // Sanitize env tiers exactly as the layered spawn env does (reserved/ + // malformed/NUL filtering), so the resolved authority matches what launches. + let record_env = merged_user_env(&BTreeMap::new(), &record.env_vars); + + // 1. record native — only for runtimes with a real native key. + if let Some(nk) = native_key { + if let Some(raw) = get_ci(&record_env, nk) { + if let Some(v) = norm(raw) { + return Some(v); + } + } + } + // 2. canonical column — normalized (raw passthrough for contract-less). + if let Some(raw) = record.effort_level.as_deref() { + if let Some(v) = norm(raw) { + return Some(v); + } + } + // 3. record legacy alias — only when the native key differs from it. + if let Some(nk) = native_key { + if nk != LEGACY_THINKING_EFFORT_KEY { + if let Some(raw) = get_ci(&record_env, LEGACY_THINKING_EFFORT_KEY) { + if let Some(v) = norm(raw) { + return Some(v); + } + } + } + } + // Env tiers below require a native key to read. + let nk = native_key?; + + // 4. persona (native, then legacy) — sanitized like the layered spawn env. + let persona_env = merged_user_env(&BTreeMap::new(), &live_persona_env(personas, persona_id)); + if let Some(v) = effort_tier_alias(&persona_env, nk, norm, true) { + return Some(v); + } + // 5. global (native only). + let global = merged_user_env(&BTreeMap::new(), global_env); + if let Some(v) = effort_tier_alias(&global, nk, norm, false) { + return Some(v); + } + // 6. definition (native only) — author-controlled; reserved keys stripped. + if let Some(def) = harness_def { + let def_env: BTreeMap = def + .env + .iter() + .filter(|(k, _)| !is_reserved_env_key(k)) + .map(|(k, v)| (k.clone(), v.clone())) + .collect(); + if let Some(v) = effort_tier_alias(&def_env, nk, norm, false) { + return Some(v); + } + } + // 7. baked build floor (native only). + if let Some(raw) = get_ci(baked_env, nk) { + if let Some(v) = norm(raw) { + return Some(v); + } + } + None +} + +/// Combined spawn seam: baked-env write + effort strip + emit. +/// +/// Called by `apply_effort_to_spawn_command` in `runtime.rs` (production path) +/// and by `effort_cmd_tests` (test seam). Deleting `build_buzz_agent_provider_defaults` +/// or `apply_effort_launch_to_command` inside turns the production-sequence tests RED. +/// Deleting the outer `apply_effort_to_spawn_command` call from `spawn_agent_child` +/// is a compile error — `spawn_with_effort_proof` consumes the returned `EffortApplied` +/// token, so removing the binding leaves `effort` undefined at the spawn site. +pub(crate) fn apply_spawn_effort_env( + cmd: &mut std::process::Command, + record: &ManagedAgentRecord, + runtime: Option<&KnownAcpRuntime>, + personas: &[AgentDefinition], + persona_id: Option<&str>, + global_env: &BTreeMap, + baked_env: &BTreeMap, +) { + crate::managed_agents::agent_env::build_buzz_agent_provider_defaults(cmd); + let launch = effort_launch_projection( + record, runtime, personas, persona_id, global_env, None, baked_env, + ); + apply_effort_launch_to_command(cmd, &launch); +} + +#[cfg(test)] +#[path = "effort_tests.rs"] +mod tests; diff --git a/desktop/src-tauri/src/managed_agents/config_bridge/effort_cmd_tests.rs b/desktop/src-tauri/src/managed_agents/config_bridge/effort_cmd_tests.rs new file mode 100644 index 0000000000..172f373d79 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/config_bridge/effort_cmd_tests.rs @@ -0,0 +1,356 @@ +//! Command-boundary strip and production-sequence seam tests for effort. +//! +//! Split from `effort_tests.rs` to stay within the file-size ratchet. +//! Covers `strip_effort_keys_from_command` tombstone assertions and the +//! child-process spawn sequence via `apply_effort_to_spawn_command`. +//! +//! The production-sequence tests call `apply_effort_to_spawn_command` +//! (`runtime.rs`), the same function `spawn_agent_child` calls. Deleting +//! `apply_spawn_effort_env` from that wrapper turns these tests RED. +//! Deleting the `apply_effort_to_spawn_command` call from `spawn_agent_child` +//! is a compile error: `spawn_with_effort_proof` consumes the returned +//! `EffortApplied` by value, so removing the binding leaves `effort` undefined +//! at the spawn site. + +use std::collections::BTreeMap; + +use super::super::strip_effort_keys_from_command; +use super::*; +use crate::managed_agents::runtime::apply_effort_to_spawn_command; + +// -------------------------------------------------------------------------- +// Command-boundary strip (P1: inherited + baked collision) +// -------------------------------------------------------------------------- + +/// ACP sentinel baked/inherited collision: registered for removal after strip. +#[test] +fn strip_removes_baked_acp_sentinel_collision() { + let mut cmd = std::process::Command::new("echo"); + cmd.env(ACP_KEY, "high"); + strip_effort_keys_from_command(&mut cmd); + let removed = cmd + .get_envs() + .any(|(key, value)| key == ACP_KEY && value.is_none()); + assert!( + removed, + "ACP sentinel must be registered for removal after strip" + ); +} + +/// Baked `GOOSE_THINKING_EFFORT` collision: stripped before descriptor overlay. +#[test] +fn strip_removes_baked_goose_native_key_collision() { + let mut cmd = std::process::Command::new("echo"); + cmd.env(GOOSE_KEY, "high"); + strip_effort_keys_from_command(&mut cmd); + let removed = cmd + .get_envs() + .any(|(key, value)| key == GOOSE_KEY && value.is_none()); + assert!( + removed, + "GOOSE_THINKING_EFFORT must be registered for removal after strip" + ); +} + +/// Baked `BUZZ_AGENT_THINKING_EFFORT` collision: legacy alias stripped. +#[test] +fn strip_removes_baked_buzz_agent_native_key_collision() { + let mut cmd = std::process::Command::new("echo"); + cmd.env(BUZZ_AGENT_KEY, "medium"); + strip_effort_keys_from_command(&mut cmd); + let removed = cmd + .get_envs() + .any(|(key, value)| key == BUZZ_AGENT_KEY && value.is_none()); + assert!( + removed, + "BUZZ_AGENT_THINKING_EFFORT must be registered for removal after strip" + ); +} + +/// Lowercase inherited key: both canonical and lowercase variants are stripped. +#[test] +fn strip_removes_lowercase_goose_key_inherited_from_shell() { + let lower = GOOSE_KEY.to_ascii_lowercase(); + let mut cmd = std::process::Command::new("echo"); + cmd.env(&lower, "stale"); + strip_effort_keys_from_command(&mut cmd); + let removed = cmd + .get_envs() + .any(|(key, value)| key == lower.as_str() && value.is_none()); + assert!( + removed, + "lowercase GOOSE key must be registered for removal" + ); +} + +/// Custom passthrough: non-suppress-set keys are not removed. +#[test] +fn strip_does_not_remove_unrelated_env_key() { + let mut cmd = std::process::Command::new("echo"); + cmd.env("MY_CUSTOM_EFFORT", "high"); + strip_effort_keys_from_command(&mut cmd); + let value_present = cmd + .get_envs() + .any(|(key, value)| key == "MY_CUSTOM_EFFORT" && value.is_some()); + assert!( + value_present, + "strip must not touch env keys outside the suppress set" + ); +} + +// -------------------------------------------------------------------------- +// Production-sequence seam tests +// -------------------------------------------------------------------------- +// Spawn the child directly so its actual env is the ground truth. +// These call `apply_effort_to_spawn_command` (in `runtime.rs`), the same function +// `spawn_agent_child` calls. Deleting `apply_spawn_effort_env` from that wrapper +// turns these tests RED. The `EffortApplied` sentinel makes the call site in +// `spawn_agent_child` a compile-time requirement. +// Deletion proofs: +// - remove `build_buzz_agent_provider_defaults` inside → baked keys leak; +// - remove `effort_launch_projection` → suppress list is empty, keys leak; +// - remove `apply_effort_launch_to_command` → stale keys remain, assertion fails. +// +// Inherited-state tests seed the parent env via `std::env::set_var` under the +// crate-wide env lock (`crate::managed_agents::lock_env_mutex`). `EnvVarGuard` +// restores the exact prior value (including non-Unicode) in `Drop`, so panics +// do not leak the seeded value into unrelated child-spawn tests. + +/// RAII guard: snapshots a process-env variable and restores the exact prior +/// value (or removes it if it was absent) on `Drop`, even on panic. +/// Uses `OsString` so a pre-existing non-Unicode value is restored exactly +/// rather than being silently lost. +struct EnvVarGuard { + key: String, + prior: Option, +} +impl EnvVarGuard { + fn set(key: &str, value: &str) -> Self { + let prior = std::env::var_os(key); + #[allow(deprecated)] + unsafe { + std::env::set_var(key, value); + } + Self { + key: key.to_string(), + prior, + } + } +} +impl Drop for EnvVarGuard { + fn drop(&mut self) { + #[allow(deprecated)] + unsafe { + match &self.prior { + Some(v) => std::env::set_var(&self.key, v), + None => std::env::remove_var(&self.key), + } + } + } +} + +fn run_env_cmd(cmd: &mut std::process::Command) -> String { + let output = cmd + .output() + .expect("env-dump command must be executable on this host"); + assert!( + output.status.success(), + "env command failed: {:?}", + output.status + ); + String::from_utf8_lossy(&output.stdout).to_string() +} + +/// After projection + strip + emit, the child sees exactly the projected Goose +/// key with no collision. Inherited lowercase key is seeded via EnvVarGuard. +#[test] +#[cfg(not(target_os = "windows"))] +fn production_sequence_goose_inherited_collision_resolved_in_child() { + let lower = GOOSE_KEY.to_ascii_lowercase(); + let _lock = crate::managed_agents::lock_env_mutex(); + let _guard = EnvVarGuard::set(&lower, "inherited-low"); + + let mut cmd = std::process::Command::new("/usr/bin/env"); + cmd.env(GOOSE_KEY, "baked-high"); + cmd.env(BUZZ_AGENT_KEY, "legacy-medium"); + cmd.env("MY_AGENT_CONFIG", "keep-me"); + + let mut r = record(); + r.effort_level = Some("high".into()); + let _effort = apply_effort_to_spawn_command( + &mut cmd, + &r, + Some(goose()), + &[], + None, + &BTreeMap::new(), + &BTreeMap::new(), + ); + let child_env = run_env_cmd(&mut cmd); + + assert!( + child_env.contains(&format!("{GOOSE_KEY}=high")), + "child must receive the projected Goose key; env:\n{child_env}" + ); + assert!( + !child_env.contains(BUZZ_AGENT_KEY), + "legacy buzz-agent key must not reach child; env:\n{child_env}" + ); + assert!( + !child_env.contains(ACP_KEY), + "ACP sentinel must not reach child for Goose; env:\n{child_env}" + ); + assert!( + !child_env.contains(&format!("{lower}=inherited-low")), + "inherited lowercase key must be stripped; env:\n{child_env}" + ); + assert!( + child_env.contains("MY_AGENT_CONFIG=keep-me"), + "unrelated key must survive; env:\n{child_env}" + ); + let effort_key_count = [GOOSE_KEY, BUZZ_AGENT_KEY, ACP_KEY] + .iter() + .filter(|k| child_env.contains(&format!("{k}="))) + .count(); + assert_eq!( + effort_key_count, 1, + "exactly one effort key must reach child; env:\n{child_env}" + ); +} + +/// Windows: OS case-folds env keys, so stripping canonical removes ALL case variants. +#[test] +#[cfg(target_os = "windows")] +fn production_sequence_arbitrary_mixedcase_collision_absent_from_child_windows() { + let mixed = "GoOsE_ThInKiNg_EfFoRt"; + let mut cmd = std::process::Command::new("cmd"); + cmd.args(["/c", "set"]); + cmd.env_clear(); + cmd.env(mixed, "stale-mixed"); + let mut r = record(); + r.effort_level = Some("high".into()); + let _effort = apply_effort_to_spawn_command( + &mut cmd, + &r, + Some(goose()), + &[], + None, + &BTreeMap::new(), + &BTreeMap::new(), + ); + let child_env = run_env_cmd(&mut cmd); + assert!( + child_env + .to_ascii_uppercase() + .contains(&format!("{}=HIGH", GOOSE_KEY.to_ascii_uppercase())), + "canonical effort key must reach the child; env:\n{child_env}" + ); + assert!( + !child_env + .to_ascii_uppercase() + .contains(&format!("{}=STALE-MIXED", mixed.to_ascii_uppercase())), + "mixed-case effort key must not reach the child; env:\n{child_env}" + ); +} + +/// Custom passthrough: non-suppress-set effort keys survive the production sequence. +#[test] +#[cfg(not(target_os = "windows"))] +fn production_sequence_custom_passthrough_survives() { + let mut cmd = std::process::Command::new("/usr/bin/env"); + cmd.env_clear(); + cmd.env("MY_HARNESS_EFFORT", "high"); + cmd.env("MY_UNRELATED_CONFIG", "keep"); + let _effort = apply_effort_to_spawn_command( + &mut cmd, + &record(), + None, + &[], + None, + &BTreeMap::new(), + &BTreeMap::new(), + ); + let child_env = run_env_cmd(&mut cmd); + assert!( + child_env.contains("MY_HARNESS_EFFORT=high"), + "custom key must survive; env:\n{child_env}" + ); + assert!( + child_env.contains("MY_UNRELATED_CONFIG=keep"), + "unrelated key must survive; env:\n{child_env}" + ); +} + +/// Custom-runtime: inherited `GOOSE_THINKING_EFFORT` survives (unknown-runtime +/// suppress set excludes foreign effort keys). +#[test] +#[cfg(not(target_os = "windows"))] +fn production_sequence_custom_inherited_goose_key_survives() { + let _lock = crate::managed_agents::lock_env_mutex(); + let _guard = EnvVarGuard::set(GOOSE_KEY, "inherited-high"); + let mut cmd = std::process::Command::new("/usr/bin/env"); + let _effort = apply_effort_to_spawn_command( + &mut cmd, + &record(), + None, + &[], + None, + &BTreeMap::new(), + &BTreeMap::new(), + ); + let child_env = run_env_cmd(&mut cmd); + assert!( + child_env.contains(&format!("{GOOSE_KEY}=inherited-high")), + "GOOSE key must survive for unknown runtime; env:\n{child_env}" + ); +} + +/// Custom-runtime: inherited ACP sentinel survives as pass-through (no column). +#[test] +#[cfg(not(target_os = "windows"))] +fn production_sequence_custom_inherited_acp_sentinel_survives() { + let _lock = crate::managed_agents::lock_env_mutex(); + let _guard = EnvVarGuard::set(ACP_KEY, "inherited-val"); + let mut cmd = std::process::Command::new("/usr/bin/env"); + let _effort = apply_effort_to_spawn_command( + &mut cmd, + &record(), + None, + &[], + None, + &BTreeMap::new(), + &BTreeMap::new(), + ); + let child_env = run_env_cmd(&mut cmd); + assert!( + child_env.contains(&format!("{ACP_KEY}=inherited-val")), + "ACP sentinel must survive for unknown runtime with no column; env:\n{child_env}" + ); +} + +/// Windows: custom-wrapper effort keys survive the production sequence. +#[test] +#[cfg(target_os = "windows")] +fn production_sequence_custom_passthrough_survives() { + let mut cmd = std::process::Command::new("cmd"); + cmd.args(["/c", "set"]); + cmd.env_clear(); + cmd.env("MY_HARNESS_EFFORT", "high"); + cmd.env("MY_UNRELATED_CONFIG", "keep"); + let _effort = apply_effort_to_spawn_command( + &mut cmd, + &record(), + None, + &[], + None, + &BTreeMap::new(), + &BTreeMap::new(), + ); + let child_env = run_env_cmd(&mut cmd); + assert!(child_env + .to_ascii_uppercase() + .contains("MY_HARNESS_EFFORT=HIGH")); + assert!(child_env + .to_ascii_uppercase() + .contains("MY_UNRELATED_CONFIG=KEEP")); +} diff --git a/desktop/src-tauri/src/managed_agents/config_bridge/effort_tests.rs b/desktop/src-tauri/src/managed_agents/config_bridge/effort_tests.rs new file mode 100644 index 0000000000..9c4568fceb --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/config_bridge/effort_tests.rs @@ -0,0 +1,701 @@ +//! Parity matrix for the single harness-agnostic effort projection +//! (`effort_launch_projection`, PR #4625). +//! +//! Covers, per runtime: CLEAR authority order; decisive mixed-authority; +//! `value == None` when no tier resolves; single-key emission + suppress; +//! unknown/custom-runtime ACP-sentinel fallback. + +use std::collections::BTreeMap; + +use super::{effort_launch_projection, effort_suppress_keys, EffortLaunch}; +use crate::managed_agents::custom_harnesses::HarnessDefinition; +use crate::managed_agents::discovery::{known_acp_runtime_exact, KnownAcpRuntime}; +use crate::managed_agents::types::{AgentDefinition, ManagedAgentRecord}; + +pub(super) const GOOSE_KEY: &str = "GOOSE_THINKING_EFFORT"; +pub(super) const BUZZ_AGENT_KEY: &str = "BUZZ_AGENT_THINKING_EFFORT"; +pub(super) const ACP_KEY: &str = "BUZZ_ACP_EFFORT_LEVEL"; + +pub(super) fn goose() -> &'static KnownAcpRuntime { + known_acp_runtime_exact("goose").expect("goose runtime in catalog") +} +fn claude() -> &'static KnownAcpRuntime { + known_acp_runtime_exact("claude").expect("claude runtime in catalog") +} +fn buzz_agent() -> &'static KnownAcpRuntime { + known_acp_runtime_exact("buzz-agent").expect("buzz-agent runtime in catalog") +} + +pub(super) fn record() -> ManagedAgentRecord { + ManagedAgentRecord { + pubkey: "test".to_string(), + name: "Test Agent".to_string(), + persona_id: None, + private_key_nsec: "".to_string(), + auth_tag: None, + relay_url: "ws://localhost:3000".to_string(), + avatar_url: None, + description: None, + acp_command: "buzz-acp".to_string(), + agent_command: "goose".to_string(), + agent_args: vec![], + mcp_command: "".to_string(), + turn_timeout_seconds: 300, + idle_timeout_seconds: None, + max_turn_duration_seconds: None, + parallelism: 1, + system_prompt: None, + model: None, + env_vars: BTreeMap::new(), + start_on_app_launch: false, + auto_restart_on_config_change: true, + runtime_pid: None, + backend: crate::managed_agents::types::BackendKind::Local, + backend_agent_id: None, + provider_policy_pending: false, + provider_binary_path: None, + team_id: None, + persona_team_dir: None, + persona_name_in_team: None, + team_catalog_source: None, + created_at: "".to_string(), + updated_at: "".to_string(), + last_started_at: None, + last_stopped_at: None, + last_exit_code: None, + last_error: None, + last_error_code: None, + respond_to: crate::managed_agents::types::RespondTo::OwnerOnly, + respond_to_allowlist: vec![], + display_name: None, + slug: None, + runtime: None, + name_pool: Vec::new(), + is_builtin: false, + is_active: true, + shared: false, + source_team: None, + source_team_persona_slug: None, + catalog_source: None, + definition_respond_to: None, + definition_respond_to_allowlist: Vec::new(), + definition_parallelism: None, + relay_mesh: None, + effort_level: None, + agent_command_override: None, + persona_source_version: None, + provider: None, + } +} + +fn env(pairs: &[(&str, &str)]) -> BTreeMap { + pairs + .iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect() +} + +fn persona(id: &str, env_vars: BTreeMap) -> AgentDefinition { + AgentDefinition { + id: id.to_string(), + display_name: "P".to_string(), + avatar_url: None, + description: None, + system_prompt: String::new(), + runtime: None, + model: None, + provider: None, + name_pool: vec![], + is_builtin: false, + is_active: true, + shared: false, + source_team: None, + source_team_persona_slug: None, + catalog_source: None, + team_catalog_source: None, + env_vars, + respond_to: None, + respond_to_allowlist: vec![], + parallelism: None, + created_at: String::new(), + updated_at: String::new(), + } +} + +fn harness_def(env: BTreeMap) -> HarnessDefinition { + HarnessDefinition { + id: "custom".to_string(), + label: "Custom".to_string(), + command: "custom".to_string(), + args: vec![], + env, + install_instructions_url: String::new(), + install_hint: String::new(), + } +} + +/// Convenience: project with no persona/global/definition/baked tiers. +fn project_record_only( + record: &ManagedAgentRecord, + runtime: Option<&KnownAcpRuntime>, +) -> EffortLaunch { + effort_launch_projection( + record, + runtime, + &[], + None, + &BTreeMap::new(), + None, + &BTreeMap::new(), + ) +} + +// -------------------------------------------------------------------------- +// Destination key + emission strategy per runtime +// -------------------------------------------------------------------------- + +#[test] +fn goose_emits_only_goose_key() { + let mut r = record(); + r.effort_level = Some("high".into()); + let launch = project_record_only(&r, Some(goose())); + assert_eq!(launch.value.as_deref(), Some("high")); + assert_eq!(launch.key, GOOSE_KEY); +} + +#[test] +fn claude_routes_canonical_through_acp_sentinel() { + let mut r = record(); + r.effort_level = Some("high".into()); + let launch = project_record_only(&r, Some(claude())); + // Claude has no native key: the column is the sole authority and it emits + // under the retained ACP-startup sentinel. + assert_eq!(launch.value.as_deref(), Some("high")); + assert_eq!(launch.key, ACP_KEY); +} + +#[test] +fn buzz_agent_passes_raw_contract_less_value_under_native_key() { + let mut r = record(); + // buzz-agent has no static normalization contract: a per-model value that + // Goose would reject (e.g. "minimal") passes through raw. + r.effort_level = Some("minimal".into()); + let launch = project_record_only(&r, Some(buzz_agent())); + assert_eq!(launch.value.as_deref(), Some("minimal")); + assert_eq!(launch.key, BUZZ_AGENT_KEY); +} + +#[test] +fn unknown_runtime_falls_back_to_acp_sentinel() { + let mut r = record(); + r.effort_level = Some("high".into()); + // No runtime metadata (custom/unknown adapter): preserve main's behavior — + // canonical routes through the raw ACP sentinel path. + let launch = project_record_only(&r, None); + assert_eq!(launch.value.as_deref(), Some("high")); + assert_eq!(launch.key, ACP_KEY); +} + +// -------------------------------------------------------------------------- +// CLEAR authority order + the decisive mixed-authority case +// -------------------------------------------------------------------------- + +#[test] +fn decisive_record_native_outranks_a_different_valid_column() { + // The mixed-authority pin Thufir/Will require: a valid record-native env + // key and a DIFFERENT valid canonical column must resolve to the + // record-native value — reader, local, remote, and snapshot all agree. + let mut r = record(); + r.env_vars = env(&[(GOOSE_KEY, "low")]); + r.effort_level = Some("high".into()); + let launch = project_record_only(&r, Some(goose())); + assert_eq!( + launch.value.as_deref(), + Some("low"), + "record-native env outranks the canonical column" + ); +} + +#[test] +fn canonical_column_wins_when_no_record_native() { + // No record-native key present: the column is the next tier and wins over + // lower tiers (here, persona). + let mut r = record(); + r.persona_id = Some("p".into()); + r.effort_level = Some("high".into()); + let personas = vec![persona("p", env(&[(GOOSE_KEY, "low")]))]; + let launch = effort_launch_projection( + &r, + Some(goose()), + &personas, + Some("p"), + &BTreeMap::new(), + None, + &BTreeMap::new(), + ); + assert_eq!(launch.value.as_deref(), Some("high")); +} + +#[test] +fn record_legacy_alias_wins_over_persona_for_goose() { + // Record legacy `BUZZ_AGENT_THINKING_EFFORT` outranks persona for a runtime + // whose native key differs from the legacy key. + let mut r = record(); + r.persona_id = Some("p".into()); + r.env_vars = env(&[(BUZZ_AGENT_KEY, "max")]); + let personas = vec![persona("p", env(&[(GOOSE_KEY, "low")]))]; + let launch = effort_launch_projection( + &r, + Some(goose()), + &personas, + Some("p"), + &BTreeMap::new(), + None, + &BTreeMap::new(), + ); + assert_eq!(launch.value.as_deref(), Some("max")); +} + +#[test] +fn persona_then_global_then_definition_then_baked_fall_through() { + // With no record tier set, each lower tier wins in order once the ones + // above it are absent. Verify persona > global by presence. + let mut r = record(); + r.persona_id = Some("p".into()); + let personas = vec![persona("p", env(&[(GOOSE_KEY, "high")]))]; + let global = env(&[(GOOSE_KEY, "low")]); + let launch = effort_launch_projection( + &r, + Some(goose()), + &personas, + Some("p"), + &global, + None, + &BTreeMap::new(), + ); + assert_eq!( + launch.value.as_deref(), + Some("high"), + "persona outranks global" + ); + + // Drop the persona value: global wins. + let personas = vec![persona("p", BTreeMap::new())]; + let launch = effort_launch_projection( + &r, + Some(goose()), + &personas, + Some("p"), + &global, + None, + &BTreeMap::new(), + ); + assert_eq!( + launch.value.as_deref(), + Some("low"), + "global outranks definition" + ); + + // Drop global too: definition wins. + let def = harness_def(env(&[(GOOSE_KEY, "medium")])); + let launch = effort_launch_projection( + &r, + Some(goose()), + &personas, + Some("p"), + &BTreeMap::new(), + Some(&def), + &BTreeMap::new(), + ); + assert_eq!( + launch.value.as_deref(), + Some("medium"), + "definition outranks baked" + ); + + // Drop definition: baked build floor wins. + let baked = env(&[(GOOSE_KEY, "off")]); + let launch = effort_launch_projection( + &r, + Some(goose()), + &personas, + Some("p"), + &BTreeMap::new(), + None, + &baked, + ); + assert_eq!(launch.value.as_deref(), Some("off")); +} + +// -------------------------------------------------------------------------- +// Normalization + skip-as-absent fall-through +// -------------------------------------------------------------------------- + +#[test] +fn goose_alias_column_xhigh_normalizes_to_max() { + let mut r = record(); + r.effort_level = Some("xhigh".into()); + let launch = project_record_only(&r, Some(goose())); + assert_eq!(launch.value.as_deref(), Some("max")); +} + +#[test] +fn invalid_goose_column_skips_and_falls_through_to_persona() { + // "minimal" is invalid for Goose: it skips as absent so the persona tier + // supplies the effective value (nondestructive switch policy relies on this). + let mut r = record(); + r.persona_id = Some("p".into()); + r.effort_level = Some("minimal".into()); + let personas = vec![persona("p", env(&[(GOOSE_KEY, "high")]))]; + let launch = effort_launch_projection( + &r, + Some(goose()), + &personas, + Some("p"), + &BTreeMap::new(), + None, + &BTreeMap::new(), + ); + assert_eq!(launch.value.as_deref(), Some("high")); +} + +#[test] +fn invalid_goose_value_with_no_lower_tier_is_none() { + let mut r = record(); + r.effort_level = Some("minimal".into()); + let launch = project_record_only(&r, Some(goose())); + assert_eq!( + launch.value, None, + "invalid canonical with no fallback → None" + ); +} + +#[test] +fn no_tier_set_is_none() { + let launch = project_record_only(&record(), Some(goose())); + assert_eq!(launch.value, None); +} + +// -------------------------------------------------------------------------- +// Suppression + single-key emission (the double-authority guard) +// -------------------------------------------------------------------------- + +#[test] +fn suppress_covers_all_native_legacy_and_sentinel_keys() { + let keys = effort_suppress_keys(); + assert!(keys.contains(&GOOSE_KEY), "goose native key suppressed"); + assert!( + keys.contains(&BUZZ_AGENT_KEY), + "buzz-agent native + legacy key suppressed" + ); + assert!(keys.contains(&ACP_KEY), "ACP transport sentinel suppressed"); +} + +#[test] +fn apply_strips_every_foreign_effort_key_then_emits_one() { + let mut r = record(); + r.effort_level = Some("high".into()); + let launch = project_record_only(&r, Some(goose())); + let mut launch_env = env(&[ + (ACP_KEY, "stale"), + (BUZZ_AGENT_KEY, "stale"), + (GOOSE_KEY, "stale"), + ("UNRELATED", "keep"), + ]); + launch.apply(&mut launch_env); + assert_eq!(launch_env.get(GOOSE_KEY).map(String::as_str), Some("high")); + assert_eq!(launch_env.get(ACP_KEY), None); + assert_eq!(launch_env.get(BUZZ_AGENT_KEY), None); + assert_eq!( + launch_env.get("UNRELATED").map(String::as_str), + Some("keep") + ); + let effort_keys = launch_env + .keys() + .filter(|k| effort_suppress_keys().contains(&k.as_str())) + .count(); + assert_eq!(effort_keys, 1, "exactly one effort key survives"); +} + +#[test] +fn apply_with_no_value_strips_all_effort_keys() { + let launch = project_record_only(&record(), Some(goose())); + assert_eq!(launch.value, None); + let mut launch_env = env(&[(ACP_KEY, "x"), (GOOSE_KEY, "y")]); + launch.apply(&mut launch_env); + assert!( + launch_env + .keys() + .all(|k| !effort_suppress_keys().contains(&k.as_str())), + "no effort key remains when the projection has no value" + ); +} + +#[test] +fn buzz_agent_generic_column_does_not_leak_acp_sentinel() { + let mut r = record(); + r.env_vars = env(&[(ACP_KEY, "high")]); + r.effort_level = Some("medium".into()); + let launch = project_record_only(&r, Some(buzz_agent())); + assert_eq!(launch.value.as_deref(), Some("medium")); + assert_eq!(launch.key, BUZZ_AGENT_KEY); + + let mut launch_env = r.env_vars.clone(); + launch.apply(&mut launch_env); + assert_eq!( + launch_env.get(ACP_KEY), + None, + "ACP sentinel stripped for buzz-agent" + ); + assert_eq!( + launch_env.get(BUZZ_AGENT_KEY).map(String::as_str), + Some("medium") + ); +} + +// -------------------------------------------------------------------------- +// External review fix #2 — unknown/custom runtimes restore main's pass-through +// -------------------------------------------------------------------------- + +#[test] +fn unknown_runtime_does_not_suppress_user_effort_env() { + // Regression: a custom wrapper with GOOSE_THINKING_EFFORT=high in record env + // must reach the child unchanged. For unknown runtimes `suppress` is exactly + // `[BUZZ_ACP_EFFORT_LEVEL]` — no foreign effort key is stripped. + let mut r = record(); + r.env_vars = env(&[(GOOSE_KEY, "high"), ("UNRELATED", "keep")]); + let launch = project_record_only(&r, None); + assert_eq!( + launch.suppress, + vec![ACP_KEY], + "unknown runtime suppresses only its own sentinel, never a foreign key" + ); + + let mut launch_env = r.env_vars.clone(); + launch.apply(&mut launch_env); + assert_eq!( + launch_env.get(GOOSE_KEY).map(String::as_str), + Some("high"), + "custom-wrapper effort key survives an unknown-runtime launch" + ); + assert_eq!( + launch_env.get("UNRELATED").map(String::as_str), + Some("keep") + ); +} + +#[test] +fn unknown_runtime_keeps_user_acp_sentinel_when_no_column() { + // Custom adapter with hand-set sentinel and no column: sentinel carries through. + let mut r = record(); + r.env_vars = env(&[(ACP_KEY, "low")]); + let launch = project_record_only(&r, None); + assert_eq!(launch.value, None); + assert!(launch.preserve_passthrough); + let mut launch_env = r.env_vars.clone(); + launch.apply(&mut launch_env); + assert_eq!( + launch_env.get(ACP_KEY).map(String::as_str), + Some("low"), + "hand-set sentinel survives on an unknown runtime with no column" + ); +} + +#[test] +fn unknown_runtime_collapses_mixed_case_sentinel_to_canonical_when_no_column() { + // Carl P2 (no-column): a hand-set mixed-case sentinel on a custom runtime + // must survive AND be re-emitted under the canonical spelling. Leaving the + // lowercase variant would hand the child a value the snapshot read misses. + let mut r = record(); + r.env_vars = env(&[("buzz_acp_effort_level", "low")]); + let launch = project_record_only(&r, None); + assert_eq!(launch.value, None); + let mut launch_env = r.env_vars.clone(); + launch.apply(&mut launch_env); + assert_eq!( + launch_env.get(ACP_KEY).map(String::as_str), + Some("low"), + "mixed-case pass-through sentinel re-emitted under canonical key" + ); + assert_eq!( + launch_env.get("buzz_acp_effort_level"), + None, + "mixed-case spelling is collapsed away" + ); +} + +#[test] +fn unknown_runtime_no_column_multi_variant_preserves_windows_effective_value() { + // Pass-3 IMPORTANT (Thufir): both case spellings of the sentinel survive the + // case-sensitive layer merge. Rust `Command` writes in `BTreeMap` iteration + // order into a case-folded env map (last set wins); canonical `B` sorts before + // lowercase `b`, so the lowercase `low` is written last and wins. The carry + // selects the LAST case-insensitive match, matching that. + let mut r = record(); + r.env_vars = env(&[(ACP_KEY, "high"), ("buzz_acp_effort_level", "low")]); + let launch = project_record_only(&r, None); + assert_eq!(launch.value, None); + assert!(launch.preserve_passthrough); + let mut launch_env = r.env_vars.clone(); + launch.apply(&mut launch_env); + assert_eq!( + launch_env.get(ACP_KEY).map(String::as_str), + Some("low"), + "carry preserves the last-in-iteration-order value the Windows child receives" + ); + assert_eq!(launch_env.get("buzz_acp_effort_level"), None); + // Mutation: reverting the carry to exact-first `get_ci` selects `high`. +} + +#[test] +fn unknown_runtime_column_wins_over_mixed_case_sentinel() { + // Carl P2 (with-column): canonical column plus mixed-case sentinel. Column + // wins; the projection strips ALL case variants of the sentinel before emit. + let mut r = record(); + r.effort_level = Some("high".into()); + r.env_vars = env(&[("buzz_acp_effort_level", "low")]); + let launch = project_record_only(&r, None); + assert_eq!(launch.value.as_deref(), Some("high")); + assert_eq!(launch.key, ACP_KEY); + let mut launch_env = r.env_vars.clone(); + launch.apply(&mut launch_env); + assert_eq!( + launch_env.get(ACP_KEY).map(String::as_str), + Some("high"), + "column wins, emitted under canonical sentinel key" + ); + assert_eq!(launch_env.get("buzz_acp_effort_level"), None); + // Mutation: empty suppress set leaves `buzz_acp_effort_level=low` in child. +} + +#[test] +fn unknown_runtime_column_still_emits_under_acp_sentinel() { + // The retained compatibility emission: an unknown runtime with a canonical + // column emits it raw under the ACP sentinel (matches the PR-body decision). + let mut r = record(); + r.effort_level = Some("high".into()); + let launch = project_record_only(&r, None); + assert_eq!(launch.value.as_deref(), Some("high")); + assert_eq!(launch.key, ACP_KEY); +} + +// -------------------------------------------------------------------------- +// External review fix #3 — destination-vocabulary validation at projection +// -------------------------------------------------------------------------- + +#[test] +fn goose_off_column_skips_for_buzz_agent_destination() { + // Regression: canonical column `off` is valid Goose but NOT a buzz-agent + // effort. Switching a record with effort_level=off to buzz-agent must NOT + // emit BUZZ_AGENT_THINKING_EFFORT=off — parse_thinking_effort rejects it and + // the child exits 2. Invalid → skip as absent → no key emitted. + let mut r = record(); + r.effort_level = Some("off".into()); + let launch = project_record_only(&r, Some(buzz_agent())); + assert_eq!( + launch.value, None, + "foreign canonical `off` skipped for buzz-agent's vocabulary" + ); + + let mut launch_env = BTreeMap::new(); + launch.apply(&mut launch_env); + assert_eq!( + launch_env.get(BUZZ_AGENT_KEY), + None, + "no effort key emitted when the value is outside the destination vocabulary" + ); +} + +#[test] +fn buzz_agent_minimal_column_skips_for_goose_destination() { + // The reverse: `minimal` is a valid buzz-agent effort but invalid Goose, so + // switching to Goose skips it as absent (already covered by normalization, + // pinned here as the symmetric vocabulary case). + let mut r = record(); + r.effort_level = Some("minimal".into()); + let launch = project_record_only(&r, Some(goose())); + assert_eq!(launch.value, None); +} + +#[test] +fn buzz_agent_accepts_its_own_distinct_efforts() { + // buzz-agent keeps xhigh and max distinct (no Goose-style xhigh→max + // collapse): both are valid and pass through unchanged. + for v in ["xhigh", "max", "none", "minimal"] { + let mut r = record(); + r.effort_level = Some(v.into()); + let launch = project_record_only(&r, Some(buzz_agent())); + assert_eq!( + launch.value.as_deref(), + Some(v), + "buzz-agent accepts `{v}` verbatim (no alias collapse)" + ); + } +} + +// -------------------------------------------------------------------------- +// External review fix #4 — case-insensitive suppression / lookup +// -------------------------------------------------------------------------- + +#[test] +fn mixed_case_native_key_is_read_and_wins() { + // Windows Command case-folds env names, so `goose_thinking_effort` is the + // same variable as the canonical form. The tier reader must find it. + let mut r = record(); + r.env_vars = env(&[("goose_thinking_effort", "low")]); + r.effort_level = Some("high".into()); + let launch = project_record_only(&r, Some(goose())); + assert_eq!( + launch.value.as_deref(), + Some("low"), + "mixed-case record-native key is read and outranks the column" + ); +} + +#[test] +fn duplicate_case_native_variants_resolve_to_windows_effective_value() { + // Carl P2 (r8): both case spellings of a known native key in the record env. + // Rust `Command` writes in `BTreeMap` order into a case-folded map; canonical + // `GOOSE_THINKING_EFFORT` sorts before lowercase, so the lowercase `high` is + // written last and wins. `get_ci` must select the LAST match, not exact-case. + let mut r = record(); + r.env_vars = env(&[(GOOSE_KEY, "low"), ("goose_thinking_effort", "high")]); + let launch = project_record_only(&r, Some(goose())); + assert_eq!( + launch.value.as_deref(), + Some("high"), + "known-runtime native lookup selects the last case variant Windows Command sets" + ); + // Mutation: reverting `get_ci` to exact-first selects `low`. +} + +#[test] +fn apply_strips_mixed_case_effort_keys() { + // A hand-set mixed-case foreign effort key must be swept, not left to + // shadow the projected value once Windows case-folds it at spawn. + let mut r = record(); + r.effort_level = Some("high".into()); + let launch = project_record_only(&r, Some(goose())); + + let mut launch_env = env(&[ + ("Goose_Thinking_Effort", "stale"), + ("buzz_acp_effort_level", "stale"), + ("UNRELATED", "keep"), + ]); + launch.apply(&mut launch_env); + + // Only the canonical projected key remains; both mixed-case foreign keys + // are gone. + assert_eq!(launch_env.get(GOOSE_KEY).map(String::as_str), Some("high")); + assert_eq!(launch_env.get("Goose_Thinking_Effort"), None); + assert_eq!(launch_env.get("buzz_acp_effort_level"), None); + assert_eq!( + launch_env.get("UNRELATED").map(String::as_str), + Some("keep") + ); +} + +// Command-boundary strip and production-sequence tests are in the sibling module. +#[cfg(test)] +#[path = "effort_cmd_tests.rs"] +mod cmd_tests; diff --git a/desktop/src-tauri/src/managed_agents/config_bridge/mod.rs b/desktop/src-tauri/src/managed_agents/config_bridge/mod.rs index f8b045fc72..9ac2e5bc10 100644 --- a/desktop/src-tauri/src/managed_agents/config_bridge/mod.rs +++ b/desktop/src-tauri/src/managed_agents/config_bridge/mod.rs @@ -1,6 +1,7 @@ mod buzz_agent; mod claude; mod codex; +pub(crate) mod effort; mod goose; pub(crate) mod reader; mod schema_walker; @@ -8,6 +9,25 @@ pub(crate) mod types; pub(crate) use types::*; +/// The legacy effort env key written by pre-migration saves. +/// +/// Harnesses whose native `thinking_env_var` differs from this constant +/// (currently: Goose uses `GOOSE_THINKING_EFFORT`) need the alias resolver in +/// [`effort`] to translate old saves. buzz-agent's native key equals this +/// constant, so no aliasing applies there. +pub(crate) const LEGACY_THINKING_EFFORT_KEY: &str = "BUZZ_AGENT_THINKING_EFFORT"; + +/// Return all known native thinking-effort env keys across all runtimes. +/// +/// Derived from `KNOWN_ACP_RUNTIMES::thinking_env_var` so that adding a new +/// runtime automatically participates in foreign-key suppression without a +/// separate constant to update. +pub(crate) fn all_known_effort_keys() -> impl Iterator { + crate::managed_agents::discovery::KNOWN_ACP_RUNTIMES + .iter() + .filter_map(|rt| rt.thinking_env_var) +} + /// Read the goose harness config file (`~/.config/goose/config.yaml`). /// /// Used by readiness evaluation to silence requirements that are already diff --git a/desktop/src-tauri/src/managed_agents/config_bridge/reader.rs b/desktop/src-tauri/src/managed_agents/config_bridge/reader.rs index 93827635e9..84eec8db33 100644 --- a/desktop/src-tauri/src/managed_agents/config_bridge/reader.rs +++ b/desktop/src-tauri/src/managed_agents/config_bridge/reader.rs @@ -1,7 +1,10 @@ +use crate::managed_agents::discovery::EffortNormalization; use crate::managed_agents::discovery::KnownAcpRuntime; use crate::managed_agents::types::ManagedAgentRecord; +use super::effort::effort_tier_alias; use super::types::*; +use super::LEGACY_THINKING_EFFORT_KEY; /// Build the full config surface for an agent, merging all tiers. /// @@ -40,6 +43,8 @@ pub(crate) fn read_config_surface( let provider_env_var = runtime_meta.and_then(|m| m.provider_env_var); let provider_locked = runtime_meta.is_some_and(|m| m.provider_locked); let thinking_env_var = runtime_meta.and_then(|m| m.thinking_env_var); + let effort_norm = runtime_meta.and_then(|m| m.effort_normalization); + let effort_accepted = runtime_meta.and_then(|m| m.effort_accepted_values); let supports_acp_native = runtime_meta.is_some_and(|m| m.supports_acp_native_config); let required_fields: &[&str] = runtime_meta .map(|m| m.required_normalized_fields) @@ -93,6 +98,8 @@ pub(crate) fn read_config_surface( &acp_effort, effort_option.map(|o| o.config_id.as_str()), thinking_env_var, + effort_norm, + effort_accepted, is_pre_spawn, tiers, ), @@ -126,7 +133,7 @@ pub(crate) fn read_config_surface( .collect(); // Collect the env var keys already covered by normalized fields. - let normalized_env_keys: Vec<&str> = [ + let mut normalized_env_keys: Vec<&str> = [ model_env_var, provider_env_var, thinking_env_var, @@ -138,10 +145,40 @@ pub(crate) fn read_config_surface( .flatten() .collect(); - // Tier 2a: remaining env vars not covered by normalized fields. + // Hide the legacy effort key from advanced only when it actually wins the + // record tier: native and canonical column are absent/invalid, then legacy + // normalizes. Otherwise `build_thinking_field` represents another winner + // and the legacy key stays editable in Advanced. + let record_legacy_consumed = thinking_env_var + .zip(effort_norm) + .is_some_and(|(native, norm)| { + native != LEGACY_THINKING_EFFORT_KEY + && super::effort::get_ci(&record.env_vars, native) + .and_then(|v| norm.normalize_str(v)) + .is_none() + && record + .effort_level + .as_deref() + .and_then(|v| norm.normalize_str(v)) + .is_none() + && super::effort::get_ci(&record.env_vars, LEGACY_THINKING_EFFORT_KEY) + .and_then(|v| norm.normalize_str(v)) + .is_some() + }); + if record_legacy_consumed { + normalized_env_keys.push(LEGACY_THINKING_EFFORT_KEY); + } + + // Tier 2a: remaining env vars not covered by normalized fields. Matching is + // ASCII-case-insensitive so a mixed-case managed key (e.g. Windows + // `goose_thinking_effort`) the launch projection already consumed is hidden + // from Advanced rather than shown as a spurious editable extra. let mut advanced = advanced; for (k, v) in &record.env_vars { - if normalized_env_keys.contains(&k.as_str()) { + if normalized_env_keys + .iter() + .any(|nk| nk.eq_ignore_ascii_case(k)) + { continue; } if file_config.extra.contains_key(k) { @@ -542,40 +579,92 @@ fn build_thinking_field( acp_effort: &Option, effort_config_id: Option<&str>, thinking_env_var: Option<&str>, + effort_norm: Option<&'static EffortNormalization>, + effort_accepted: Option<&'static [&'static str]>, is_pre_spawn: bool, tiers: &InheritedConfigTiers, ) -> Option { - // Tier ordering: - // record env > record.effort_level (canonical Buzz-persisted) > ACP > - // persona env > global env > definition env > config file. + // Tier ordering (mirrors the launch projection in `config_bridge::effort`, + // plus the two reader-only tiers the projection has no input for — live ACP + // and the on-disk config file): + // record native > canonical column > record legacy > ACP > + // persona > global > definition > config file. // - // `record.effort_level` is the B5 canonical value: the effort a spawn will - // actually apply at next session start (via `apply_effort_env`). Sitting it - // above ACP means the panel shows the *configured* value the agent will - // launch with rather than a stale live-session reading — the record can't - // be masked by, nor mask, the running value silently. - let [rec_env, pers_env, glob_env, def_env] = thinking_env_var - .map(|k| { - env_candidates( - k, - &record.env_vars, - &tiers.persona_env, - &tiers.global_env, - &tiers.definition_env, - ) - }) - .unwrap_or([None, None, None, None]); + // Every candidate is normalized through the runtime's declared contract + // (`effort_norm`) before validity, precedence, override tracking, and the B + // same-value collapse — the SAME normalizer the launch projection applies — + // so the panel and the next spawn resolve one effective value AND authority. + // For contract runtimes an invalid value (e.g. Goose `minimal`) normalizes + // to `None` and is skipped as absent so a lower tier can win; aliases + // (`none`→`off`, `xhigh`→`max`, case-fold) canonicalize. Contract-less + // runtimes (buzz-agent, Claude/Codex column) pass raw. + let norm = |raw: &str| -> Option { + super::effort::normalize_effort(effort_norm, effort_accepted, raw) + }; - let canonical_effort = record.effort_level.as_deref(); + // Record tiers, split exactly as the projection resolves them: native env + // strictly above the canonical column, legacy env strictly below it. + let rec_native = thinking_env_var + .and_then(|k| super::effort::get_ci(&record.env_vars, k)) + .and_then(|v| norm(v)); + let column = record.effort_level.as_deref().and_then(&norm); + let rec_legacy = thinking_env_var + .filter(|k| *k != LEGACY_THINKING_EFFORT_KEY) + .and_then(|_| super::effort::get_ci(&record.env_vars, LEGACY_THINKING_EFFORT_KEY)) + .and_then(|v| norm(v)); + + // Inherited env tiers: persona resolves native-then-legacy; global and + // definition are native-only (legacy alias excluded), matching the launch + // projection's per-tier alias policy. + let pers = thinking_env_var.and_then(|k| effort_tier_alias(&tiers.persona_env, k, norm, true)); + let glob = thinking_env_var.and_then(|k| effort_tier_alias(&tiers.global_env, k, norm, false)); + let def = + thinking_env_var.and_then(|k| effort_tier_alias(&tiers.definition_env, k, norm, false)); + let file = file_effort.as_deref().and_then(&norm); + + // Live ACP value: normalized through the runtime CONTRACT only, never the + // persisted `effort_accepted` vocabulary. The ACP running value comes from + // the session's own config-option namespace (e.g. buzz-agent reports + // `default` for its live thinking-level option) — it is a descriptive + // "currently running" fact, never emitted to a spawn, so the + // destination-vocabulary gate that guards the writable tiers must not skip + // it. Goose still canonicalizes (its ACP option values ARE effort values); + // contract-less runtimes pass raw. The matched `config_id` is preserved for + // `write_via` regardless of value validity. + let acp_norm = acp_effort + .as_deref() + .and_then(|v| super::effort::normalize_effort(effort_norm, None, v)); + + // B same-value collapse: when NO record-level authority exists and the live + // ACP value exactly equals what inheritance would already resolve to, drop + // ACP so the panel shows the true baseline origin ("Global default") rather + // than a spurious "Runtime override (this session only)" — the session is + // almost certainly echoing what spawn injected. When a record tier is + // present it wins over ACP anyway, so ACP stays only for override tracking. + let record_present = rec_native.is_some() || column.is_some() || rec_legacy.is_some(); + let baseline_first = [ + pers.as_deref(), + glob.as_deref(), + def.as_deref(), + file.as_deref(), + ] + .into_iter() + .flatten() + .next(); + let acp_for_list = match (record_present, acp_norm.as_deref(), baseline_first) { + (false, Some(a), Some(b)) if a == b => None, + _ => acp_norm.as_deref(), + }; let tiers_list: &[(Option<&str>, ConfigOrigin)] = &[ - (rec_env, ConfigOrigin::BuzzExplicit), - (canonical_effort, ConfigOrigin::BuzzExplicit), - (acp_effort.as_deref(), ConfigOrigin::AcpConfigOption), - (pers_env, ConfigOrigin::PersonaDefault), - (glob_env, ConfigOrigin::GlobalDefault), - (def_env, ConfigOrigin::HarnessDefault), - (file_effort.as_deref(), ConfigOrigin::ConfigFile), + (rec_native.as_deref(), ConfigOrigin::BuzzExplicit), + (column.as_deref(), ConfigOrigin::BuzzExplicit), + (rec_legacy.as_deref(), ConfigOrigin::BuzzExplicit), + (acp_for_list, ConfigOrigin::AcpConfigOption), + (pers.as_deref(), ConfigOrigin::PersonaDefault), + (glob.as_deref(), ConfigOrigin::GlobalDefault), + (def.as_deref(), ConfigOrigin::HarnessDefault), + (file.as_deref(), ConfigOrigin::ConfigFile), ]; let (value, origin, overridden_value, overridden_origin) = resolve_with_override(tiers_list)?; @@ -746,11 +835,20 @@ fn find_config_option_value(cache: &SessionConfigCache, category: &str) -> Optio /// config id (Claude Code uses `id="effort"`). Selecting by category — not by /// a hardcoded id — is what lets the running value, the write config id, and /// the picker options all derive from one entry. +/// +/// `thought_level` is preferred; the legacy invented category `effort` is a +/// fallback for old test fixtures and pre-canonical adapters. The fallback +/// fires only when `thought_level` is entirely absent — an advertised-but-unset +/// `thought_level` entry is still returned (its `current_value` is `None`), so +/// the reader never flips write-routing to the legacy `effort` config id. fn find_effort_option(cache: &SessionConfigCache) -> Option<&AcpConfigOptionEntry> { - cache - .config_options - .iter() - .find(|o| o.category.as_deref() == Some("thought_level")) + let by_category = |category: &str| { + cache + .config_options + .iter() + .find(|o| o.category.as_deref() == Some(category)) + }; + by_category("thought_level").or_else(|| by_category("effort")) } fn has_config_option(cache: Option<&SessionConfigCache>, category: &str) -> bool { diff --git a/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests.rs b/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests.rs index 64834d1017..34b4f1496f 100644 --- a/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests.rs +++ b/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests.rs @@ -28,7 +28,7 @@ fn with_goose_path_root(value: Option<&str>, body: impl FnOnce() -> T) -> T { } fn test_runtime() -> &'static KnownAcpRuntime { - &KnownAcpRuntime { + static RUNTIME: KnownAcpRuntime = KnownAcpRuntime { id: "goose", label: "Goose", commands: &["goose"], @@ -54,13 +54,16 @@ fn test_runtime() -> &'static KnownAcpRuntime { config_file_format: Some("yaml"), supports_acp_native_config: true, thinking_env_var: Some("GOOSE_THINKING_EFFORT"), + effort_normalization: Some(&crate::managed_agents::discovery::GOOSE_EFFORT_NORMALIZATION), + effort_accepted_values: None, max_tokens_env_var: Some("GOOSE_MAX_TOKENS"), context_limit_env_var: Some("GOOSE_CONTEXT_LIMIT"), max_rounds_env_var: None, required_normalized_fields: &["model", "provider"], login_hint: None, auth_probe_args: None, - } + }; + &RUNTIME } fn test_record() -> ManagedAgentRecord { @@ -647,6 +650,8 @@ fn buzz_agent_runtime() -> &'static KnownAcpRuntime { config_file_format: None, supports_acp_native_config: false, thinking_env_var: Some("BUZZ_AGENT_THINKING_EFFORT"), + effort_normalization: None, + effort_accepted_values: None, max_tokens_env_var: Some("BUZZ_AGENT_MAX_OUTPUT_TOKENS"), context_limit_env_var: Some("BUZZ_AGENT_MAX_CONTEXT_TOKENS"), max_rounds_env_var: Some("BUZZ_AGENT_MAX_ROUNDS"), @@ -958,3 +963,6 @@ fn numeric_max_tokens_inherits_from_global_env() { // ── Extended tests (split file to respect line-count ratchet) ──────────────── #[path = "reader_tests_ext.rs"] mod ext; + +#[path = "reader_tests_ext2.rs"] +mod ext2; diff --git a/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests_ext.rs b/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests_ext.rs index 0974bf9c58..fc18a51c62 100644 --- a/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests_ext.rs +++ b/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests_ext.rs @@ -518,3 +518,460 @@ fn claude_default_config_dir_reports_static_settings_path() { .as_deref() .is_some_and(|p| !p.starts_with('~'))); } + +// ── Goose-contract reader normalization + reader/projection parity ──────────── +// +// The reader (`build_thinking_field`) and the launch projection +// (`effort_launch_projection`) must resolve one effective value AND one +// authority for every record/inherited input, or the config panel displays a +// different effort than the next spawn launches. `test_runtime()` is Goose with +// `effort_normalization = GOOSE_EFFORT_NORMALIZATION`, so these exercise the +// normalization gate, alias canonicalization, invalid-value skip/fallthrough, +// and the decisive mixed-authority case — the phase-1 behavior block, not just +// fixture metadata. + +use crate::managed_agents::config_bridge::effort::effort_launch_projection; + +/// Drive the projection from the SAME record + global env the reader sees, so +/// the two resolvers are compared on identical inputs. Persona/definition tiers +/// use distinct input shapes across the two layers and are covered separately; +/// record-native/column/legacy and global are expressible identically here, +/// which is exactly where the authority-order contract is decisive. +fn projection_value( + record: &ManagedAgentRecord, + global_env: &BTreeMap, +) -> Option { + effort_launch_projection( + record, + Some(test_runtime()), + &[], + None, + global_env, + None, + &BTreeMap::new(), + ) + .value +} + +/// Goose invalid record-native value (`minimal` — not in the Goose contract) +/// skips as absent so a valid lower tier wins, IDENTICALLY in reader and +/// projection. This is Thufir's named regression: a raw winner in the panel +/// while the launch skips it. +#[test] +fn goose_invalid_record_native_skips_to_column_in_reader_and_projection() { + let mut record = test_record(); + record + .env_vars + .insert("GOOSE_THINKING_EFFORT".to_string(), "minimal".to_string()); + record.effort_level = Some("high".to_string()); + let runtime = test_runtime(); + + let surface = read_config_surface(&record, Some(runtime), None, &no_tiers(), None); + let effort = surface + .normalized + .thinking_effort + .expect("valid column must win when native is invalid"); + // Reader: invalid native skipped, column wins. + assert_eq!(effort.value.as_deref(), Some("high")); + assert_eq!(effort.origin, ConfigOrigin::BuzzExplicit); + // Projection agrees on value. + assert_eq!( + projection_value(&record, &BTreeMap::new()).as_deref(), + Some("high") + ); +} + +/// Goose alias canonicalization: `xhigh` → `max` in BOTH resolvers (record +/// native), `none` → `off` (column). +#[test] +fn goose_aliases_canonicalize_in_reader_and_projection() { + let mut record = test_record(); + record + .env_vars + .insert("GOOSE_THINKING_EFFORT".to_string(), "xhigh".to_string()); + let runtime = test_runtime(); + + let surface = read_config_surface(&record, Some(runtime), None, &no_tiers(), None); + let effort = surface.normalized.thinking_effort.unwrap(); + assert_eq!(effort.value.as_deref(), Some("max")); + assert_eq!(effort.origin, ConfigOrigin::BuzzExplicit); + assert_eq!( + projection_value(&record, &BTreeMap::new()).as_deref(), + Some("max") + ); + + let mut record2 = test_record(); + record2.effort_level = Some("none".to_string()); + let surface2 = read_config_surface(&record2, Some(runtime), None, &no_tiers(), None); + assert_eq!( + surface2 + .normalized + .thinking_effort + .unwrap() + .value + .as_deref(), + Some("off") + ); + assert_eq!( + projection_value(&record2, &BTreeMap::new()).as_deref(), + Some("off") + ); +} + +/// The decisive mixed-authority case (Thufir/Paul acceptance pin): a valid +/// record-native value and a DIFFERENT valid column → the native value wins in +/// reader and projection alike. The column is the surfaced override baseline. +#[test] +fn goose_record_native_outranks_column_in_reader_and_projection() { + let mut record = test_record(); + record + .env_vars + .insert("GOOSE_THINKING_EFFORT".to_string(), "high".to_string()); + record.effort_level = Some("low".to_string()); + let runtime = test_runtime(); + + let surface = read_config_surface(&record, Some(runtime), None, &no_tiers(), None); + let effort = surface.normalized.thinking_effort.unwrap(); + assert_eq!(effort.value.as_deref(), Some("high")); + assert_eq!(effort.origin, ConfigOrigin::BuzzExplicit); + // Column is the overridden baseline (next distinct tier below native). + assert_eq!(effort.overridden_value.as_deref(), Some("low")); + // Projection resolves the same authority. + assert_eq!( + projection_value(&record, &BTreeMap::new()).as_deref(), + Some("high") + ); +} + +/// Invalid column AND invalid native → both skip; a valid global tier wins in +/// the reader, and the projection (driven from the same global env) agrees. +#[test] +fn goose_invalid_record_tiers_fall_through_to_global_in_reader_and_projection() { + let mut record = test_record(); + record + .env_vars + .insert("GOOSE_THINKING_EFFORT".to_string(), "bogus".to_string()); + record.effort_level = Some("alsobad".to_string()); + let runtime = test_runtime(); + let mut global = BTreeMap::new(); + global.insert("GOOSE_THINKING_EFFORT".to_string(), "medium".to_string()); + let tiers = global_env_tiers("GOOSE_THINKING_EFFORT", "medium"); + + let surface = read_config_surface(&record, Some(runtime), None, &tiers, None); + let effort = surface + .normalized + .thinking_effort + .expect("global tier must win when both record tiers are invalid"); + assert_eq!(effort.value.as_deref(), Some("medium")); + assert_eq!(effort.origin, ConfigOrigin::GlobalDefault); + assert_eq!( + projection_value(&record, &global).as_deref(), + Some("medium") + ); +} + +/// Goose legacy alias (`BUZZ_AGENT_THINKING_EFFORT`) is accepted for the record +/// tier below the column, canonicalized, in reader and projection alike. +#[test] +fn goose_record_legacy_alias_below_column_in_reader_and_projection() { + let mut record = test_record(); + record.env_vars.insert( + "BUZZ_AGENT_THINKING_EFFORT".to_string(), + "xhigh".to_string(), + ); + let runtime = test_runtime(); + + let surface = read_config_surface(&record, Some(runtime), None, &no_tiers(), None); + let effort = surface + .normalized + .thinking_effort + .expect("record legacy alias must surface when native and column are absent"); + assert_eq!(effort.value.as_deref(), Some("max")); + assert_eq!(effort.origin, ConfigOrigin::BuzzExplicit); + assert_eq!( + projection_value(&record, &BTreeMap::new()).as_deref(), + Some("max") + ); +} + +/// B same-value collapse: no record authority, live ACP echoes the inherited +/// global value → the panel shows the inherited origin (GlobalDefault), not a +/// spurious per-session AcpConfigOption override. +#[test] +fn goose_acp_equal_to_global_collapses_to_global_origin() { + let record = test_record(); + let runtime = test_runtime(); + let cache = SessionConfigCache { + config_options: vec![AcpConfigOptionEntry { + config_id: "thinking_effort".to_string(), + category: Some("thought_level".to_string()), + display_name: Some("Effort".to_string()), + current_value: Some("medium".to_string()), + options: vec![], + }], + available_modes: vec![], + available_models: vec![], + current_model: None, + model_overridden: false, + goose_native_config: None, + captured_at: "".to_string(), + }; + let tiers = global_env_tiers("GOOSE_THINKING_EFFORT", "medium"); + + let surface = read_config_surface(&record, Some(runtime), Some(&cache), &tiers, None); + let effort = surface.normalized.thinking_effort.unwrap(); + assert_eq!(effort.value.as_deref(), Some("medium")); + assert_eq!( + effort.origin, + ConfigOrigin::GlobalDefault, + "ACP echoing the inherited value must not masquerade as a session override" + ); +} + +/// B same-value collapse does NOT fire on genuine divergence: live ACP differs +/// from the inherited baseline → ACP wins as the per-session override, global +/// is the surfaced baseline. +#[test] +fn goose_acp_diverging_from_global_wins_as_override() { + let record = test_record(); + let runtime = test_runtime(); + let cache = SessionConfigCache { + config_options: vec![AcpConfigOptionEntry { + config_id: "thinking_effort".to_string(), + category: Some("thought_level".to_string()), + display_name: Some("Effort".to_string()), + current_value: Some("low".to_string()), + options: vec![], + }], + available_modes: vec![], + available_models: vec![], + current_model: None, + model_overridden: false, + goose_native_config: None, + captured_at: "".to_string(), + }; + let tiers = global_env_tiers("GOOSE_THINKING_EFFORT", "high"); + + let surface = read_config_surface(&record, Some(runtime), Some(&cache), &tiers, None); + let effort = surface.normalized.thinking_effort.unwrap(); + assert_eq!(effort.value.as_deref(), Some("low")); + assert_eq!(effort.origin, ConfigOrigin::AcpConfigOption); + assert_eq!(effort.overridden_value.as_deref(), Some("high")); + assert_eq!(effort.overridden_origin, Some(ConfigOrigin::GlobalDefault)); +} + +/// Invalid live ACP value is skipped as absent; a valid record tier wins and +/// no phantom ACP override is surfaced. +#[test] +fn goose_invalid_acp_skips_and_record_wins() { + let mut record = test_record(); + record.effort_level = Some("high".to_string()); + let runtime = test_runtime(); + let cache = SessionConfigCache { + config_options: vec![AcpConfigOptionEntry { + config_id: "thinking_effort".to_string(), + category: Some("thought_level".to_string()), + display_name: Some("Effort".to_string()), + current_value: Some("garbage".to_string()), + options: vec![], + }], + available_modes: vec![], + available_models: vec![], + current_model: None, + model_overridden: false, + goose_native_config: None, + captured_at: "".to_string(), + }; + + let surface = read_config_surface(&record, Some(runtime), Some(&cache), &no_tiers(), None); + let effort = surface.normalized.thinking_effort.unwrap(); + assert_eq!(effort.value.as_deref(), Some("high")); + assert_eq!(effort.origin, ConfigOrigin::BuzzExplicit); + assert_eq!( + projection_value(&record, &BTreeMap::new()).as_deref(), + Some("high") + ); +} + +// ── Consumed-legacy Advanced suppression (F2) ──────────────────────────────── +// +// When the record's native effort key is absent/invalid and the legacy key +// (`BUZZ_AGENT_THINKING_EFFORT`) supplies the normalized record effort, the +// legacy key must NOT also re-appear as a generic Advanced field — one +// persisted fact must not surface through two controls. Invalid/unconsumed +// legacy values stay visible in Advanced. + +/// Record has valid legacy `BUZZ_AGENT_THINKING_EFFORT=high` and no native +/// `GOOSE_THINKING_EFFORT` → effort surfaces from the legacy alias AND the +/// legacy key must NOT re-appear in Advanced. +#[test] +fn record_consumed_legacy_effort_hidden_from_advanced_reader() { + let mut record = test_record(); + record + .env_vars + .insert("BUZZ_AGENT_THINKING_EFFORT".to_string(), "high".to_string()); + let runtime = test_runtime(); // Goose (native GOOSE_THINKING_EFFORT) + + let surface = with_goose_path_root(Some("/nonexistent"), || { + read_config_surface(&record, Some(runtime), None, &no_tiers(), None) + }); + + let effort = surface + .normalized + .thinking_effort + .expect("valid legacy value must surface as effort via record-tier alias"); + assert_eq!(effort.value.as_deref(), Some("high")); + + let advanced_keys: Vec<&str> = surface.advanced.iter().map(|f| f.key.as_str()).collect(); + assert!( + !advanced_keys.contains(&"BUZZ_AGENT_THINKING_EFFORT"), + "consumed legacy effort key must not double-emit in advanced; got {advanced_keys:?}" + ); +} + +/// A valid legacy value shadowed by the canonical column is not consumed, so +/// it remains editable in Advanced rather than silently resurfacing later if +/// the column is cleared. +#[test] +fn record_legacy_effort_shadowed_by_column_stays_visible_in_advanced_reader() { + let mut record = test_record(); + record.effort_level = Some("high".to_string()); + record + .env_vars + .insert("BUZZ_AGENT_THINKING_EFFORT".to_string(), "low".to_string()); + let runtime = test_runtime(); // Goose + + let surface = with_goose_path_root(Some("/nonexistent"), || { + read_config_surface(&record, Some(runtime), None, &no_tiers(), None) + }); + + let effort = surface + .normalized + .thinking_effort + .expect("canonical column must win over legacy record effort"); + assert_eq!(effort.value.as_deref(), Some("high")); + let advanced_keys: Vec<&str> = surface.advanced.iter().map(|f| f.key.as_str()).collect(); + assert!( + advanced_keys.contains(&"BUZZ_AGENT_THINKING_EFFORT"), + "valid but unconsumed record legacy must remain visible in Advanced; got {advanced_keys:?}" + ); +} + +/// An invalid legacy `BUZZ_AGENT_THINKING_EFFORT` value is unconsumed, so it +/// stays visible in Advanced. +#[test] +fn record_invalid_legacy_effort_stays_visible_in_advanced_reader() { + let mut record = test_record(); + record.env_vars.insert( + "BUZZ_AGENT_THINKING_EFFORT".to_string(), + "bogus".to_string(), + ); + let runtime = test_runtime(); // Goose + + let surface = with_goose_path_root(Some("/nonexistent"), || { + read_config_surface(&record, Some(runtime), None, &no_tiers(), None) + }); + + assert!( + surface.normalized.thinking_effort.is_none(), + "invalid legacy value must not be consumed as effort" + ); + let advanced_keys: Vec<&str> = surface.advanced.iter().map(|f| f.key.as_str()).collect(); + assert!( + advanced_keys.contains(&"BUZZ_AGENT_THINKING_EFFORT"), + "unconsumed legacy key must stay visible in advanced; got {advanced_keys:?}" + ); +} + +// ── F4: legacy `effort` category fallback in find_effort_option ────────────── +// +// `thought_level` is preferred; the legacy invented category `effort` is a +// fallback for pre-canonical adapters. An advertised-but-unset `thought_level` +// must NOT fall through to a set `effort` (that would route the write to the +// wrong config_id), but a cache that advertises only `effort` must still +// surface a thinking field and write route. + +/// `thought_level` present but unset, `effort` present and set → effort must +/// NOT surface from the live cache (no fallthrough); write routing never picks +/// up the legacy `effort` config id. +#[test] +fn unset_thought_level_does_not_fall_through_to_effort_category() { + let record = test_record(); + let runtime = test_runtime(); // Goose + let cache = SessionConfigCache { + config_options: vec![ + AcpConfigOptionEntry { + config_id: "thinking_effort".to_string(), + category: Some("thought_level".to_string()), + display_name: Some("Thinking Effort".to_string()), + current_value: None, // advertised but unset + options: vec![], + }, + AcpConfigOptionEntry { + config_id: "effort".to_string(), + category: Some("effort".to_string()), + display_name: Some("Effort (legacy)".to_string()), + current_value: Some("low".to_string()), + options: vec![], + }, + ], + available_modes: vec![], + available_models: vec![], + current_model: None, + model_overridden: false, + goose_native_config: None, + captured_at: "".to_string(), + }; + + let surface = with_goose_path_root(Some("/nonexistent"), || { + read_config_surface(&record, Some(runtime), Some(&cache), &no_tiers(), None) + }); + + assert!( + surface.normalized.thinking_effort.is_none(), + "unset thought_level must not fall through to the legacy effort category" + ); +} + +/// `effort` category present and set, no `thought_level` at all → legacy +/// fallback still surfaces the field and routes the write to the matched +/// `effort` config id. +#[test] +fn effort_category_fallback_used_when_thought_level_absent() { + let record = test_record(); + let runtime = test_runtime(); // Goose + let cache = SessionConfigCache { + config_options: vec![AcpConfigOptionEntry { + config_id: "effort".to_string(), + category: Some("effort".to_string()), + display_name: Some("Effort (legacy)".to_string()), + current_value: Some("high".to_string()), + options: vec![], + }], + available_modes: vec![], + available_models: vec![], + current_model: None, + model_overridden: false, + goose_native_config: None, + captured_at: "".to_string(), + }; + + let surface = with_goose_path_root(Some("/nonexistent"), || { + read_config_surface(&record, Some(runtime), Some(&cache), &no_tiers(), None) + }); + + let effort = surface + .normalized + .thinking_effort + .expect("legacy effort category must surface when thought_level is absent"); + assert_eq!(effort.value.as_deref(), Some("high")); + assert!( + matches!( + &effort.write_via, + ConfigWriteMechanism::AcpSetConfigOption { config_id } + if config_id == "effort" + ), + "write route must use the legacy effort config_id when it is the only category; got {:?}", + effort.write_via + ); +} diff --git a/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests_ext2.rs b/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests_ext2.rs new file mode 100644 index 0000000000..0c5aa69c40 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests_ext2.rs @@ -0,0 +1,69 @@ +//! Additional tests for `config_bridge/reader.rs` — split out to keep +//! `reader_tests_ext.rs` under the 1000-line file-size ratchet. +//! +//! Included as `mod ext2` inside `reader_tests.rs`, so `use super::*` gives +//! access to all helpers and types from that module. + +use super::*; + +// ── Fix (external review #4): reader resolves record effort keys ───────────── +// case-insensitively, matching the launch projection. +// +// Windows `Command` case-folds env names, so a hand-set `goose_thinking_effort` +// is the same variable as its canonical form. The reader must resolve it as the +// record-native effort winner AND hide it from Advanced, or the panel disagrees +// with the child the launch projection already consumed the key for. + +/// Mixed-case native record key `goose_thinking_effort=high` wins the record +/// tier and is hidden from Advanced (not shown as a spurious editable extra). +#[test] +fn record_mixed_case_native_effort_wins_and_hidden_from_advanced_reader() { + let mut record = test_record(); + record + .env_vars + .insert("goose_thinking_effort".to_string(), "high".to_string()); + let runtime = test_runtime(); // Goose (native GOOSE_THINKING_EFFORT) + + let surface = with_goose_path_root(Some("/nonexistent"), || { + read_config_surface(&record, Some(runtime), None, &no_tiers(), None) + }); + + let effort = surface + .normalized + .thinking_effort + .expect("mixed-case native key must surface as the record effort winner"); + assert_eq!(effort.value.as_deref(), Some("high")); + + let advanced_keys: Vec<&str> = surface.advanced.iter().map(|f| f.key.as_str()).collect(); + assert!( + !advanced_keys.contains(&"goose_thinking_effort"), + "consumed mixed-case native effort key must not appear in advanced; got {advanced_keys:?}" + ); +} + +/// Mixed-case legacy record key `buzz_agent_thinking_effort=high` (no native, +/// no column) supplies the record effort AND is hidden from Advanced. +#[test] +fn record_mixed_case_legacy_effort_consumed_and_hidden_from_advanced_reader() { + let mut record = test_record(); + record + .env_vars + .insert("buzz_agent_thinking_effort".to_string(), "high".to_string()); + let runtime = test_runtime(); // Goose + + let surface = with_goose_path_root(Some("/nonexistent"), || { + read_config_surface(&record, Some(runtime), None, &no_tiers(), None) + }); + + let effort = surface + .normalized + .thinking_effort + .expect("mixed-case legacy key must surface as effort via record-tier alias"); + assert_eq!(effort.value.as_deref(), Some("high")); + + let advanced_keys: Vec<&str> = surface.advanced.iter().map(|f| f.key.as_str()).collect(); + assert!( + !advanced_keys.contains(&"buzz_agent_thinking_effort"), + "consumed mixed-case legacy effort key must not appear in advanced; got {advanced_keys:?}" + ); +} diff --git a/desktop/src-tauri/src/managed_agents/discovery.rs b/desktop/src-tauri/src/managed_agents/discovery.rs index 1ee7e6e556..84f88e406b 100644 --- a/desktop/src-tauri/src/managed_agents/discovery.rs +++ b/desktop/src-tauri/src/managed_agents/discovery.rs @@ -15,6 +15,8 @@ mod presets; mod runtime_metadata; #[macro_use] mod windows_install; +mod catalog; +pub(crate) use catalog::KNOWN_ACP_RUNTIMES; pub use login_shell::{find_nvm_default_bin, login_shell_path}; pub(crate) use login_shell::{find_via_login_shell, refresh_login_shell_path}; #[cfg(test)] @@ -26,7 +28,10 @@ pub(crate) use presets::{ preset_harness_ids, }; use presets::{preset_catalog_entry, PRESET_HARNESSES}; +pub(crate) use runtime_metadata::EffortNormalization; pub(crate) use runtime_metadata::KnownAcpRuntime; +#[cfg(test)] +pub(crate) use runtime_metadata::GOOSE_EFFORT_NORMALIZATION; const GOOSE_AVATAR_URL: &str = "https://goose-docs.ai/img/logo_dark.png"; const CLAUDE_CODE_AVATAR_URL: &str = "https://anthropic.gallerycdn.vsassets.io/extensions/anthropic/claude-code/2.1.77/1773707456892/Microsoft.VisualStudio.Services.Icons.Default"; @@ -83,144 +88,6 @@ fn common_binary_paths() -> &'static [PathBuf] { }) } -const KNOWN_ACP_RUNTIMES: &[KnownAcpRuntime] = &[ - KnownAcpRuntime { - id: "goose", - label: "Goose", - commands: &["goose"], - aliases: &[], - avatar_url: GOOSE_AVATAR_URL, - mcp_command: None, - mcp_hooks: false, - underlying_cli: Some("goose"), - cli_install_commands: &["curl -fsSL https://github.com/aaif-goose/goose/releases/download/stable/download_cli.sh | CONFIGURE=false bash"], - // Goose's stable release currently publishes only the Unix installer; - // its official Windows instructions intentionally point at this main-branch script. - cli_install_commands_windows: &[windows_install_command!("goose", "https://raw.githubusercontent.com/aaif-goose/goose/main/download_cli.ps1", "$env:CONFIGURE='false'; ")], - adapter_install_commands: &[], - cli_install_instructions_url: "https://goose-docs.ai/docs/getting-started/installation/", - adapter_install_instructions_url: "", - cli_install_hint: "Buzz talks to Goose through the Goose CLI.", - adapter_install_hint: "", - skill_dir: Some(".goose/skills"), - supports_acp_model_switching: false, - model_env_var: Some("GOOSE_MODEL"), - provider_env_var: Some("GOOSE_PROVIDER"), - provider_locked: false, - default_env: &[("GOOSE_MODE", "auto")], - config_file_path: Some("~/.config/goose/config.yaml"), - config_file_format: Some("yaml"), - supports_acp_native_config: true, - thinking_env_var: Some("GOOSE_THINKING_EFFORT"), - max_tokens_env_var: Some("GOOSE_MAX_TOKENS"), - context_limit_env_var: Some("GOOSE_CONTEXT_LIMIT"), - max_rounds_env_var: None, - required_normalized_fields: &["model", "provider"], - login_hint: None, - auth_probe_args: None, - }, - KnownAcpRuntime { - id: "claude", - label: "Claude Code", - commands: &["claude-agent-acp", "claude-code-acp"], - aliases: &["claude-code", "claudecode"], - avatar_url: CLAUDE_CODE_AVATAR_URL, - mcp_command: None, - mcp_hooks: false, - underlying_cli: Some("claude"), - cli_install_commands: &["curl -fsSL https://claude.ai/install.sh | bash"], - cli_install_commands_windows: &[windows_install_command!("claude", "https://claude.ai/install.ps1")], - adapter_install_commands: &["npm install -g @agentclientprotocol/claude-agent-acp"], - cli_install_instructions_url: "https://code.claude.com/docs/en/getting-started", - adapter_install_instructions_url: "https://github.com/agentclientprotocol/claude-agent-acp", - cli_install_hint: "Buzz talks to Claude Code through the Claude Code CLI.", - adapter_install_hint: "Buzz talks to the Claude Code CLI through an ACP adapter. Install it with: npm install -g @agentclientprotocol/claude-agent-acp.", - skill_dir: Some(".claude/skills"), - supports_acp_model_switching: false, - model_env_var: None, - provider_env_var: None, - provider_locked: true, - default_env: &[], - config_file_path: Some("~/.claude/settings.json"), - config_file_format: Some("json"), - supports_acp_native_config: false, - thinking_env_var: None, - max_tokens_env_var: None, - context_limit_env_var: None, - max_rounds_env_var: None, - required_normalized_fields: &[], - login_hint: Some("Run the Claude CLI to complete authentication."), - auth_probe_args: Some(&["claude", "auth", "status"]), - }, - KnownAcpRuntime { - id: "codex", - label: "Codex", - commands: &["codex-acp"], - aliases: &[], - avatar_url: CODEX_AVATAR_URL, - mcp_command: Some("buzz-dev-mcp"), - mcp_hooks: false, - underlying_cli: Some("codex"), - cli_install_commands: &["curl -fsSL https://chatgpt.com/codex/install.sh | sh"], - cli_install_commands_windows: &[windows_install_command!("codex", "https://chatgpt.com/codex/install.ps1")], - adapter_install_commands: &["npm install -g @agentclientprotocol/codex-acp"], - cli_install_instructions_url: "https://developers.openai.com/codex/cli/", - adapter_install_instructions_url: "https://github.com/agentclientprotocol/codex-acp", - cli_install_hint: "Buzz talks to Codex through the Codex CLI.", - adapter_install_hint: "Buzz talks to the Codex CLI through an ACP adapter. Install it with: npm install -g @agentclientprotocol/codex-acp.", - skill_dir: Some(".codex/skills"), - supports_acp_model_switching: false, - model_env_var: None, - provider_env_var: None, - provider_locked: false, - default_env: &[], - config_file_path: Some("~/.codex/config.toml"), - config_file_format: Some("toml"), - supports_acp_native_config: false, - thinking_env_var: None, - max_tokens_env_var: None, - context_limit_env_var: None, - max_rounds_env_var: None, - required_normalized_fields: &[], - login_hint: Some("Run `codex login` to authenticate."), - // Verified: `codex login status` exits 0 when logged in, non-zero otherwise. - auth_probe_args: Some(&["codex", "login", "status"]), - }, - KnownAcpRuntime { - id: "buzz-agent", - label: "Buzz Agent", - commands: &["buzz-agent"], - aliases: &[], - avatar_url: BUZZ_AGENT_AVATAR_URL, - mcp_command: Some("buzz-dev-mcp"), - mcp_hooks: true, - underlying_cli: None, - cli_install_commands: &[], - cli_install_commands_windows: &[], - adapter_install_commands: &[], - cli_install_instructions_url: "https://github.com/block/buzz", - adapter_install_instructions_url: "https://github.com/block/buzz", - cli_install_hint: "Ships with the Buzz desktop app.", - adapter_install_hint: "", - skill_dir: None, - supports_acp_model_switching: true, - model_env_var: Some("BUZZ_AGENT_MODEL"), - provider_env_var: Some("BUZZ_AGENT_PROVIDER"), - provider_locked: false, - default_env: &[], - config_file_path: None, - config_file_format: None, - supports_acp_native_config: false, - thinking_env_var: Some("BUZZ_AGENT_THINKING_EFFORT"), - max_tokens_env_var: Some("BUZZ_AGENT_MAX_OUTPUT_TOKENS"), - context_limit_env_var: Some("BUZZ_AGENT_MAX_CONTEXT_TOKENS"), - max_rounds_env_var: Some("BUZZ_AGENT_MAX_ROUNDS"), - required_normalized_fields: &["model", "provider"], - login_hint: None, - auth_probe_args: None, - }, -]; - /// Skill discovery directories declared by known runtimes. pub(crate) fn known_skill_dirs() -> impl Iterator { KNOWN_ACP_RUNTIMES.iter().filter_map(|p| p.skill_dir) @@ -375,7 +242,11 @@ pub fn effective_agent_command( } mod overrides; -pub use overrides::{apply_agent_command_update, create_time_agent_command_override}; +pub use overrides::remove_record_effort_aliases; +pub use overrides::{ + apply_agent_command_update, apply_env_vars_then_effort_transition, + create_time_agent_command_override, +}; /// Prefix of the typed dangling-harness error produced by /// `try_record_agent_command` / `resolve_effective_harness_descriptor`. @@ -1168,6 +1039,9 @@ fn discover_acp_runtime_phase1(runtime: &'static KnownAcpRuntime, force: bool) - model_env_var: runtime.model_env_var.map(str::to_string), provider_env_var: runtime.provider_env_var.map(str::to_string), thinking_env_var: runtime.thinking_env_var.map(str::to_string), + effort_canonical_values: runtime + .effort_normalization + .map(|norm| norm.canonical.iter().map(|s| s.to_string()).collect()), max_tokens_env_var: runtime.max_tokens_env_var.map(str::to_string), context_limit_env_var: runtime.context_limit_env_var.map(str::to_string), max_rounds_env_var: runtime.max_rounds_env_var.map(str::to_string), @@ -1308,6 +1182,7 @@ pub fn discover_acp_runtimes_from( model_env_var: None, provider_env_var: None, thinking_env_var: None, + effort_canonical_values: None, max_tokens_env_var: None, context_limit_env_var: None, max_rounds_env_var: None, diff --git a/desktop/src-tauri/src/managed_agents/discovery/catalog.rs b/desktop/src-tauri/src/managed_agents/discovery/catalog.rs new file mode 100644 index 0000000000..fecf792f21 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/discovery/catalog.rs @@ -0,0 +1,156 @@ +//! The known-ACP-runtime catalog. Extracted from `discovery.rs` as pure data +//! (mirroring `presets::PRESET_HARNESSES`) so the module stays under the +//! file-size ratchet. The `windows_install_command!` macro is in textual scope +//! here because this module is declared after `#[macro_use] mod windows_install` +//! in the parent. + +use super::runtime_metadata::{ + KnownAcpRuntime, BUZZ_AGENT_EFFORT_VALUES, GOOSE_EFFORT_NORMALIZATION, +}; +use super::{BUZZ_AGENT_AVATAR_URL, CLAUDE_CODE_AVATAR_URL, CODEX_AVATAR_URL, GOOSE_AVATAR_URL}; + +pub(crate) const KNOWN_ACP_RUNTIMES: &[KnownAcpRuntime] = &[ + KnownAcpRuntime { + id: "goose", + label: "Goose", + commands: &["goose"], + aliases: &[], + avatar_url: GOOSE_AVATAR_URL, + mcp_command: None, + mcp_hooks: false, + underlying_cli: Some("goose"), + cli_install_commands: &["curl -fsSL https://github.com/aaif-goose/goose/releases/download/stable/download_cli.sh | CONFIGURE=false bash"], + // Goose's stable release currently publishes only the Unix installer; + // its official Windows instructions intentionally point at this main-branch script. + cli_install_commands_windows: &[windows_install_command!("goose", "https://raw.githubusercontent.com/aaif-goose/goose/main/download_cli.ps1", "$env:CONFIGURE='false'; ")], + adapter_install_commands: &[], + cli_install_instructions_url: "https://goose-docs.ai/docs/getting-started/installation/", + adapter_install_instructions_url: "", + cli_install_hint: "Buzz talks to Goose through the Goose CLI.", + adapter_install_hint: "", + skill_dir: Some(".goose/skills"), + supports_acp_model_switching: false, + model_env_var: Some("GOOSE_MODEL"), + provider_env_var: Some("GOOSE_PROVIDER"), + provider_locked: false, + default_env: &[("GOOSE_MODE", "auto")], + config_file_path: Some("~/.config/goose/config.yaml"), + config_file_format: Some("yaml"), + supports_acp_native_config: true, + thinking_env_var: Some("GOOSE_THINKING_EFFORT"), + effort_normalization: Some(&GOOSE_EFFORT_NORMALIZATION), + effort_accepted_values: None, // goose: validated via effort_normalization + max_tokens_env_var: Some("GOOSE_MAX_TOKENS"), + context_limit_env_var: Some("GOOSE_CONTEXT_LIMIT"), + max_rounds_env_var: None, + required_normalized_fields: &["model", "provider"], + login_hint: None, + auth_probe_args: None, + }, + KnownAcpRuntime { + id: "claude", + label: "Claude Code", + commands: &["claude-agent-acp", "claude-code-acp"], + aliases: &["claude-code", "claudecode"], + avatar_url: CLAUDE_CODE_AVATAR_URL, + mcp_command: None, + mcp_hooks: false, + underlying_cli: Some("claude"), + cli_install_commands: &["curl -fsSL https://claude.ai/install.sh | bash"], + cli_install_commands_windows: &[windows_install_command!("claude", "https://claude.ai/install.ps1")], + adapter_install_commands: &["npm install -g @agentclientprotocol/claude-agent-acp"], + cli_install_instructions_url: "https://code.claude.com/docs/en/getting-started", + adapter_install_instructions_url: "https://github.com/agentclientprotocol/claude-agent-acp", + cli_install_hint: "Buzz talks to Claude Code through the Claude Code CLI.", + adapter_install_hint: "Buzz talks to the Claude Code CLI through an ACP adapter. Install it with: npm install -g @agentclientprotocol/claude-agent-acp.", + skill_dir: Some(".claude/skills"), + supports_acp_model_switching: false, + model_env_var: None, + provider_env_var: None, + provider_locked: true, + default_env: &[], + config_file_path: Some("~/.claude/settings.json"), + config_file_format: Some("json"), + supports_acp_native_config: false, + thinking_env_var: None, + effort_normalization: None, // claude: canonical routes through BUZZ_ACP_EFFORT_LEVEL (ACP startup) + effort_accepted_values: None, // claude: adapter accepts any value over BUZZ_ACP_EFFORT_LEVEL + max_tokens_env_var: None, + context_limit_env_var: None, + max_rounds_env_var: None, + required_normalized_fields: &[], + login_hint: Some("Run the Claude CLI to complete authentication."), + auth_probe_args: Some(&["claude", "auth", "status"]), + }, + KnownAcpRuntime { + id: "codex", + label: "Codex", + commands: &["codex-acp"], + aliases: &[], + avatar_url: CODEX_AVATAR_URL, + mcp_command: Some("buzz-dev-mcp"), + mcp_hooks: false, + underlying_cli: Some("codex"), + cli_install_commands: &["curl -fsSL https://chatgpt.com/codex/install.sh | sh"], + cli_install_commands_windows: &[windows_install_command!("codex", "https://chatgpt.com/codex/install.ps1")], + adapter_install_commands: &["npm install -g @agentclientprotocol/codex-acp"], + cli_install_instructions_url: "https://developers.openai.com/codex/cli/", + adapter_install_instructions_url: "https://github.com/agentclientprotocol/codex-acp", + cli_install_hint: "Buzz talks to Codex through the Codex CLI.", + adapter_install_hint: "Buzz talks to the Codex CLI through an ACP adapter. Install it with: npm install -g @agentclientprotocol/codex-acp.", + skill_dir: Some(".codex/skills"), + supports_acp_model_switching: false, + model_env_var: None, + provider_env_var: None, + provider_locked: false, + default_env: &[], + config_file_path: Some("~/.codex/config.toml"), + config_file_format: Some("toml"), + supports_acp_native_config: false, + thinking_env_var: None, + effort_normalization: None, // codex: canonical routes through BUZZ_ACP_EFFORT_LEVEL (ACP startup) + effort_accepted_values: None, // codex: adapter accepts any value over BUZZ_ACP_EFFORT_LEVEL + max_tokens_env_var: None, + context_limit_env_var: None, + max_rounds_env_var: None, + required_normalized_fields: &[], + login_hint: Some("Run `codex login` to authenticate."), + // Verified: `codex login status` exits 0 when logged in, non-zero otherwise. + auth_probe_args: Some(&["codex", "login", "status"]), + }, + KnownAcpRuntime { + id: "buzz-agent", + label: "Buzz Agent", + commands: &["buzz-agent"], + aliases: &[], + avatar_url: BUZZ_AGENT_AVATAR_URL, + mcp_command: Some("buzz-dev-mcp"), + mcp_hooks: true, + underlying_cli: None, + cli_install_commands: &[], + cli_install_commands_windows: &[], + adapter_install_commands: &[], + cli_install_instructions_url: "https://github.com/block/buzz", + adapter_install_instructions_url: "https://github.com/block/buzz", + cli_install_hint: "Ships with the Buzz desktop app.", + adapter_install_hint: "", + skill_dir: None, + supports_acp_model_switching: true, + model_env_var: Some("BUZZ_AGENT_MODEL"), + provider_env_var: Some("BUZZ_AGENT_PROVIDER"), + provider_locked: false, + default_env: &[], + config_file_path: None, + config_file_format: None, + supports_acp_native_config: false, + thinking_env_var: Some("BUZZ_AGENT_THINKING_EFFORT"), + effort_normalization: None, // buzz-agent: per-model catalog; see getProviderEffortConfig() in TS + effort_accepted_values: Some(BUZZ_AGENT_EFFORT_VALUES), // buzz-agent: parse_thinking_effort's accepted set + max_tokens_env_var: Some("BUZZ_AGENT_MAX_OUTPUT_TOKENS"), + context_limit_env_var: Some("BUZZ_AGENT_MAX_CONTEXT_TOKENS"), + max_rounds_env_var: Some("BUZZ_AGENT_MAX_ROUNDS"), + required_normalized_fields: &["model", "provider"], + login_hint: None, + auth_probe_args: None, + }, +]; diff --git a/desktop/src-tauri/src/managed_agents/discovery/overrides.rs b/desktop/src-tauri/src/managed_agents/discovery/overrides.rs index 5140bb2cdd..fa339a03b7 100644 --- a/desktop/src-tauri/src/managed_agents/discovery/overrides.rs +++ b/desktop/src-tauri/src/managed_agents/discovery/overrides.rs @@ -83,27 +83,85 @@ pub fn update_time_agent_command_override( /// Apply an explicit `agent_command` edit to `record`: persist the override /// pin decided by [`update_time_agent_command_override`], and on the inherit /// sentinel (empty/whitespace command) also clear the materialized -/// `record.runtime` so the resolution ladder falls through to the live -/// definition immediately instead of silently keeping the stale instance copy. +/// `record.runtime` AND the persisted per-instance effort column so the +/// resolution ladder falls through to the live definition immediately instead +/// of silently keeping the stale instance copy. /// -/// The runtime clear is guarded on a live persona link: for a definition-less -/// record the materialized runtime is the only harness source left after the -/// override clear, so a stray empty `agent_command` from a non-dialog caller -/// must not change what the agent runs. +/// The clears are guarded on a live persona link: for a definition-less record +/// the materialized runtime is the only harness source left after the override +/// clear, so a stray empty `agent_command` from a non-dialog caller must not +/// change what the agent runs. +/// +/// Returns `true` when the pin→inherit transition fired. The caller MUST then, +/// AFTER applying any caller-supplied `env_vars`, strip the record effort env +/// aliases via [`remove_record_effort_aliases`] — clearing them here would be +/// undone by a same-request `env_vars` replacement (see the update boundary in +/// `agent_models_update.rs`), so the alias strip is an update-boundary +/// invariant, not a helper-local one. +#[must_use] pub fn apply_agent_command_update( record: &mut crate::managed_agents::types::ManagedAgentRecord, personas: &[crate::managed_agents::types::AgentDefinition], agent_command: &str, harness_override: bool, -) { +) -> bool { record.agent_command_override = update_time_agent_command_override( record.persona_id.as_deref(), personas, Some(agent_command), harness_override, ); - if agent_command.trim().is_empty() && record.persona_id.is_some() { + let inherit_transition = agent_command.trim().is_empty() && record.persona_id.is_some(); + if inherit_transition { record.runtime = None; + // The generic canonical effort column is a per-instance pin; on the + // pin→inherit transition it is dropped so the agent inherits the + // persona/global effort. The record effort ENV aliases are stripped by + // the caller after `env_vars` is applied (see the doc above). + record.effort_level = None; + } + inherit_transition +} + +/// Strip every record-level thinking-effort env alias — all known native keys +/// plus the legacy `BUZZ_AGENT_THINKING_EFFORT` alias — from `env_vars`. +/// +/// Called at the `update_managed_agent` boundary on the pin→inherit transition, +/// AFTER caller-supplied `env_vars` have been applied, so the cleared aliases +/// cannot be reintroduced by the same request. Together with the column clear +/// in [`apply_agent_command_update`], this makes the instance drop its entire +/// per-instance effort override atomically at Save. +pub fn remove_record_effort_aliases(env_vars: &mut std::collections::BTreeMap) { + let suppress = crate::managed_agents::config_bridge::effort::effort_suppress_keys(); + env_vars.retain(|k, _| { + !suppress + .iter() + .any(|suppressed| k.eq_ignore_ascii_case(suppressed)) + }); +} + +/// Apply a same-request `env_vars` replacement and then enforce the pin→inherit +/// effort-alias strip, in that exact order. +/// +/// This is the ordering invariant Thufir's plan-of-record pins: the effort +/// column is cleared eagerly inside [`apply_agent_command_update`], but a stale +/// effort env alias in a caller-supplied `env_vars` map submitted in the SAME +/// request would otherwise survive the transition. Applying `env_vars` first, +/// then stripping the aliases only on the transition, guarantees the instance +/// cannot re-pin effort through the generic env channel while inheriting its +/// harness. `env_vars = None` leaves the record's existing env untouched; +/// validation of the supplied map is the caller's responsibility (it runs +/// before this seam at the update boundary). +pub fn apply_env_vars_then_effort_transition( + record: &mut crate::managed_agents::types::ManagedAgentRecord, + env_vars: Option>, + inherit_transition: bool, +) { + if let Some(env_vars) = env_vars { + record.env_vars = env_vars; + } + if inherit_transition { + remove_record_effort_aliases(&mut record.env_vars); } } diff --git a/desktop/src-tauri/src/managed_agents/discovery/presets.rs b/desktop/src-tauri/src/managed_agents/discovery/presets.rs index 61c589770c..438cb5eb86 100644 --- a/desktop/src-tauri/src/managed_agents/discovery/presets.rs +++ b/desktop/src-tauri/src/managed_agents/discovery/presets.rs @@ -67,6 +67,7 @@ pub(super) fn preset_catalog_entry( model_env_var: None, provider_env_var: None, thinking_env_var: None, + effort_canonical_values: None, max_tokens_env_var: None, context_limit_env_var: None, max_rounds_env_var: None, diff --git a/desktop/src-tauri/src/managed_agents/discovery/runtime_metadata.rs b/desktop/src-tauri/src/managed_agents/discovery/runtime_metadata.rs index 34edecdcd9..b68bc84c23 100644 --- a/desktop/src-tauri/src/managed_agents/discovery/runtime_metadata.rs +++ b/desktop/src-tauri/src/managed_agents/discovery/runtime_metadata.rs @@ -1,3 +1,69 @@ +/// Canonicalization contract for a harness's thinking-effort env var. +/// +/// The single value authority shared by UI choices, the spawn/deploy launch +/// projection, and the reader. All effort candidates (native env, legacy env, +/// ACP tier, file tier) are normalized through `normalize_str` before any +/// validity, precedence, override, or B-equality check. +/// +/// Source for Goose: `crates/goose-provider-types/src/thinking.rs` +/// • `FromStr` (aliases, case-insensitive): `off|disabled|none`, `low`, +/// `medium|med`, `high`, `max|xhigh` +/// • `Display` (canonical): `off`, `low`, `medium`, `high`, `max` +/// • Live ACP emits Display values via `response_builder.rs:326-337`. +pub(crate) struct EffortNormalization { + /// Canonical values in UI display order (drive choices, persistence, ACP comparison). + pub canonical: &'static [&'static str], + /// `(alias, canonical)` pairs, case-insensitive. Only aliases that differ + /// from their canonical form are listed. + pub aliases: &'static [(&'static str, &'static str)], +} + +/// Goose thinking-effort canonicalization contract. +/// +/// Source: `crates/goose-provider-types/src/thinking.rs` at Goose `2db0e31fe`. +/// Canonical Display values: `off`, `low`, `medium`, `high`, `max`. +/// Aliases (case-insensitive): `none|disabled→off`, `med→medium`, `xhigh→max`. +/// `minimal` (Buzz-only) is invalid — skipped as absent at every tier. +pub(crate) static GOOSE_EFFORT_NORMALIZATION: EffortNormalization = EffortNormalization { + canonical: &["off", "low", "medium", "high", "max"], + aliases: &[ + ("none", "off"), + ("disabled", "off"), + ("med", "medium"), + ("xhigh", "max"), + ], +}; + +/// buzz-agent's accepted persisted thinking-effort values — a validation-only +/// contract, NOT a canonicalization one. Unlike Goose, buzz-agent keeps `xhigh` +/// and `max` as *distinct* efforts, so these values are validated (invalid → +/// skip as absent) but never aliased or collapsed. +/// +/// Source of truth: `parse_thinking_effort`, `crates/buzz-agent/src/config.rs` +/// (`none|minimal|low|medium|high|xhigh|max`). A destination-vocabulary check +/// at projection time keeps a foreign canonical (e.g. Goose `off`) from being +/// emitted as `BUZZ_AGENT_THINKING_EFFORT=off`, which the parser rejects at +/// config init (child exits 2). +pub(crate) static BUZZ_AGENT_EFFORT_VALUES: &[&str] = + &["none", "minimal", "low", "medium", "high", "xhigh", "max"]; + +impl EffortNormalization { + /// Normalize `raw` to canonical form. `None` → invalid for this harness; + /// the caller must treat it as absent (skip-as-absent policy). + pub fn normalize_str(&self, raw: &str) -> Option { + let lower = raw.to_lowercase(); + if self.canonical.contains(&lower.as_str()) { + return Some(lower); + } + for &(alias, canon) in self.aliases { + if lower == alias { + return Some(canon.to_string()); + } + } + None + } +} + /// Static capabilities and installation metadata for a known ACP runtime. pub(crate) struct KnownAcpRuntime { pub id: &'static str, @@ -47,6 +113,35 @@ pub(crate) struct KnownAcpRuntime { pub config_file_format: Option<&'static str>, pub supports_acp_native_config: bool, // tier 1a: config/read+write pub thinking_env_var: Option<&'static str>, + /// Canonicalization contract for `thinking_env_var` on this harness. + /// + /// `Some(contract)` — harness uses a finite, static effort vocabulary. + /// All candidates (native env, legacy env, ACP tier, file tier) are + /// normalized through this contract before validity checks, precedence + /// resolution, override tracking, and B-equality comparison. + /// + /// `None` — harness accepts any provider/model-specific value via its own + /// catalog (buzz-agent); see `getProviderEffortConfig()` in TS for that + /// path. Contract-less does NOT mean keyless: buzz-agent still has a native + /// `thinking_env_var`, and Claude/Codex route the canonical through + /// `BUZZ_ACP_EFFORT_LEVEL` for ACP startup even with `thinking_env_var: None`. + /// + /// The single canonical authority shared by UI choices, the launch + /// projection, and the reader. No value-authority logic may live outside + /// this struct for harnesses that declare one. + pub effort_normalization: Option<&'static EffortNormalization>, + /// Accepted persisted effort values for a runtime that has NO + /// canonicalization contract but still constrains its vocabulary + /// (buzz-agent: `parse_thinking_effort`'s accepted set). Used only for + /// destination-vocabulary validation at projection/read time — a candidate + /// outside this set is skipped as absent, so a foreign canonical (e.g. + /// Goose `off`) is never emitted under `thinking_env_var` where the + /// destination parser would reject it and crash the child. + /// + /// `None` means "no validation": Goose validates through + /// `effort_normalization`; Claude/Codex and unknown/custom runtimes accept + /// any string over the `BUZZ_ACP_EFFORT_LEVEL` transport. + pub effort_accepted_values: Option<&'static [&'static str]>, /// Env var for normalizing `max_output_tokens`. `None` when the harness /// does not have a first-class env var for this field (config-file only). pub max_tokens_env_var: Option<&'static str>, diff --git a/desktop/src-tauri/src/managed_agents/discovery/tests.rs b/desktop/src-tauri/src/managed_agents/discovery/tests.rs index 577a780d6c..dc155d82f5 100644 --- a/desktop/src-tauri/src/managed_agents/discovery/tests.rs +++ b/desktop/src-tauri/src/managed_agents/discovery/tests.rs @@ -2,12 +2,13 @@ use std::path::PathBuf; use super::overrides::{divergent_agent_command_override, update_time_agent_command_override}; use super::{ - apply_agent_command_update, classify_runtime, codex_adapter_availability, - codex_adapter_is_outdated, create_time_agent_command_override, default_agent_command, - effective_agent_command, find_nvm_default_bin, is_login_shell_path_uninit, is_safe_nvm_tag, - managed_agent_avatar_url, normalize_agent_args, parse_semver_tag, probe_codex_acp_version, - record_agent_command, refresh_login_shell_path, try_record_agent_command, - BUZZ_AGENT_AVATAR_URL, CLAUDE_CODE_AVATAR_URL, CODEX_AVATAR_URL, GOOSE_AVATAR_URL, + apply_agent_command_update, apply_env_vars_then_effort_transition, classify_runtime, + codex_adapter_availability, codex_adapter_is_outdated, create_time_agent_command_override, + default_agent_command, effective_agent_command, find_nvm_default_bin, + is_login_shell_path_uninit, is_safe_nvm_tag, managed_agent_avatar_url, normalize_agent_args, + parse_semver_tag, probe_codex_acp_version, record_agent_command, refresh_login_shell_path, + remove_record_effort_aliases, try_record_agent_command, BUZZ_AGENT_AVATAR_URL, + CLAUDE_CODE_AVATAR_URL, CODEX_AVATAR_URL, GOOSE_AVATAR_URL, }; use crate::managed_agents::AcpAvailabilityStatus; @@ -606,51 +607,9 @@ fn update_time_override_preserves_pin_for_persona_less_agent() { ); } -#[test] -fn apply_agent_command_update_inherit_sentinel_clears_pin_and_runtime() { - // Choosing Inherit on a persona-linked record clears BOTH the explicit - // pin and the materialized runtime, so resolution falls through to the - // live definition immediately — not on the next spawn. - let personas = vec![persona_with_runtime("p1", Some("goose"))]; - let mut record = record_with(Some("claude"), Some("p1"), Some("codex-acp")); - - apply_agent_command_update(&mut record, &personas, "", false); - - assert_eq!(record.agent_command_override, None); - assert_eq!(record.runtime, None); - assert_eq!(record_agent_command(&record, &personas), "goose"); -} - -#[test] -fn apply_agent_command_update_sentinel_keeps_runtime_for_definition_less_record() { - // For a record with no persona link the materialized runtime is the only - // harness source left once the pin is cleared — a stray empty - // agent_command must not change what the agent runs. - let mut record = record_with(Some("claude"), None, Some("codex-acp")); - - apply_agent_command_update(&mut record, &[], "", false); - - assert_eq!(record.agent_command_override, None); - assert_eq!(record.runtime.as_deref(), Some("claude")); - assert_eq!(record_agent_command(&record, &[]), "claude-agent-acp"); -} - -#[test] -fn apply_agent_command_update_concrete_pin_keeps_materialized_runtime() { - // A concrete pick only sets the pin; the materialized runtime is left for - // the next snapshot apply. The pin shadows it in resolution either way. - let personas = vec![persona_with_runtime("p1", Some("goose"))]; - let mut record = record_with(Some("claude"), Some("p1"), None); - - apply_agent_command_update(&mut record, &personas, "codex-acp", true); - - assert_eq!(record.agent_command_override.as_deref(), Some("codex-acp")); - assert_eq!(record.runtime.as_deref(), Some("claude")); - assert_eq!(record_agent_command(&record, &personas), "codex-acp"); -} - // ── probe_codex_acp_version ─────────────────────────────────────────────────── +mod effort_clear; mod forced_discovery; mod managed_path_resolution; #[cfg(unix)] diff --git a/desktop/src-tauri/src/managed_agents/discovery/tests/effort_clear.rs b/desktop/src-tauri/src/managed_agents/discovery/tests/effort_clear.rs new file mode 100644 index 0000000000..bdeb1a1080 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/discovery/tests/effort_clear.rs @@ -0,0 +1,188 @@ +//! Backend tests for the pin→inherit effort clear (PR #4625, plan-of-record +//! item 1): the sentinel transition clears the canonical column eagerly and the +//! update boundary strips the record effort env aliases AFTER caller `env_vars` +//! is applied. Split out of `discovery/tests.rs` to hold that file under the +//! desktop file-size ratchet. +//! +//! `use super::*` pulls the parent test module's helpers (`record_with`, +//! `persona_with_runtime`, `record_agent_command`) and its imported command +//! surface (`apply_agent_command_update`, `apply_env_vars_then_effort_transition`, +//! `remove_record_effort_aliases`). + +use super::*; + +#[test] +fn apply_agent_command_update_inherit_sentinel_clears_pin_runtime_and_column() { + // Choosing Inherit on a persona-linked record clears the explicit pin, the + // materialized runtime, AND the per-instance effort column, so resolution + // falls through to the live definition immediately — not on the next spawn. + // The transition flag fires so the caller strips the record effort env + // aliases after `env_vars` is applied. + let personas = vec![persona_with_runtime("p1", Some("goose"))]; + let mut record = record_with(Some("claude"), Some("p1"), Some("codex-acp")); + record.effort_level = Some("high".into()); + + let transition = apply_agent_command_update(&mut record, &personas, "", false); + + assert!(transition, "the pin→inherit transition must be signalled"); + assert_eq!(record.agent_command_override, None); + assert_eq!(record.runtime, None); + assert_eq!( + record.effort_level, None, + "the effort column must be cleared" + ); + assert_eq!(record_agent_command(&record, &personas), "goose"); +} + +#[test] +fn apply_agent_command_update_sentinel_keeps_runtime_for_definition_less_record() { + // For a record with no persona link the materialized runtime is the only + // harness source left once the pin is cleared — a stray empty + // agent_command must not change what the agent runs, nor clear its effort. + let mut record = record_with(Some("claude"), None, Some("codex-acp")); + record.effort_level = Some("high".into()); + + let transition = apply_agent_command_update(&mut record, &[], "", false); + + assert!( + !transition, + "a definition-less stray sentinel is not a pin→inherit transition" + ); + assert_eq!(record.agent_command_override, None); + assert_eq!(record.runtime.as_deref(), Some("claude")); + assert_eq!( + record.effort_level.as_deref(), + Some("high"), + "a definition-less record must preserve its effort column" + ); + assert_eq!(record_agent_command(&record, &[]), "claude-agent-acp"); +} + +#[test] +fn apply_agent_command_update_concrete_pin_keeps_materialized_runtime_and_column() { + // A concrete pick only sets the pin; the materialized runtime and the + // effort column are left intact (no ownership transition). The pin shadows + // the runtime in resolution either way. + let personas = vec![persona_with_runtime("p1", Some("goose"))]; + let mut record = record_with(Some("claude"), Some("p1"), None); + record.effort_level = Some("high".into()); + + let transition = apply_agent_command_update(&mut record, &personas, "codex-acp", true); + + assert!( + !transition, + "a concrete pin is not a pin→inherit transition" + ); + assert_eq!(record.agent_command_override.as_deref(), Some("codex-acp")); + assert_eq!(record.runtime.as_deref(), Some("claude")); + assert_eq!( + record.effort_level.as_deref(), + Some("high"), + "a concrete pin must preserve the effort column" + ); + assert_eq!(record_agent_command(&record, &personas), "codex-acp"); +} + +#[test] +fn remove_record_effort_aliases_strips_all_known_and_legacy_keys() { + // The update-boundary alias strip: after `env_vars` is applied on the + // pin→inherit transition, every known native effort key and the legacy + // alias must be removed, while unrelated env survives. This proves the + // second half of the atomic clear that a helper-only column clear cannot. + let mut env: std::collections::BTreeMap = [ + ("GOOSE_THINKING_EFFORT", "high"), + ("BUZZ_AGENT_THINKING_EFFORT", "high"), + ("BUZZ_ACP_EFFORT_LEVEL", "high"), + ("UNRELATED_KEY", "keep"), + ] + .into_iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect(); + + remove_record_effort_aliases(&mut env); + + assert!(!env.contains_key("GOOSE_THINKING_EFFORT")); + assert!(!env.contains_key("BUZZ_AGENT_THINKING_EFFORT")); + assert!(!env.contains_key("BUZZ_ACP_EFFORT_LEVEL")); + assert_eq!( + env.get("UNRELATED_KEY").map(String::as_str), + Some("keep"), + "unrelated env must survive the effort-alias strip" + ); +} + +#[test] +fn update_boundary_inherit_sentinel_with_alias_bearing_env_vars_strips_after_apply() { + // The update-boundary ORDERING invariant (Thufir pass-3): on the pin→inherit + // transition, a SAME-REQUEST `env_vars` map carrying a stale effort alias + // must NOT survive. `apply_agent_command_update` clears the column eagerly; + // then `apply_env_vars_then_effort_transition` applies the caller env FIRST + // and strips the aliases AFTER — so the alias the request tried to + // reintroduce is gone. A helper-only test cannot prove this order. + let personas = vec![persona_with_runtime("p1", Some("goose"))]; + let mut record = record_with(Some("claude"), Some("p1"), Some("codex-acp")); + record.effort_level = Some("high".into()); + + let transition = apply_agent_command_update(&mut record, &personas, "", false); + assert!( + transition, + "empty command on a persona-linked record is inherit" + ); + + // The request replaces env_vars with a map that re-pins effort via an alias + // plus an unrelated key. + let request_env: std::collections::BTreeMap = [ + ("GOOSE_THINKING_EFFORT", "max"), + ("BUZZ_ACP_EFFORT_LEVEL", "max"), + ("UNRELATED_KEY", "keep"), + ] + .into_iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect(); + + apply_env_vars_then_effort_transition(&mut record, Some(request_env), transition); + + assert_eq!(record.effort_level, None, "column stays cleared"); + assert!( + !record.env_vars.contains_key("GOOSE_THINKING_EFFORT"), + "same-request native alias must not survive the transition" + ); + assert!( + !record.env_vars.contains_key("BUZZ_ACP_EFFORT_LEVEL"), + "same-request ACP sentinel must not survive the transition" + ); + assert_eq!( + record.env_vars.get("UNRELATED_KEY").map(String::as_str), + Some("keep"), + "unrelated env from the same request is preserved" + ); +} + +#[test] +fn update_boundary_concrete_pin_preserves_alias_bearing_env_vars() { + // No transition (concrete pin): the caller `env_vars` — including any effort + // alias — is applied verbatim and NOT stripped. Effort env is only cleared + // on the ownership transition, never on an ordinary env edit. + let personas = vec![persona_with_runtime("p1", Some("goose"))]; + let mut record = record_with(Some("claude"), Some("p1"), None); + + let transition = apply_agent_command_update(&mut record, &personas, "codex-acp", true); + assert!(!transition, "a concrete pin is not a transition"); + + let request_env: std::collections::BTreeMap = + [("GOOSE_THINKING_EFFORT", "max")] + .into_iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect(); + + apply_env_vars_then_effort_transition(&mut record, Some(request_env), transition); + + assert_eq!( + record + .env_vars + .get("GOOSE_THINKING_EFFORT") + .map(String::as_str), + Some("max"), + "without a transition the caller effort env is preserved" + ); +} diff --git a/desktop/src-tauri/src/managed_agents/mod.rs b/desktop/src-tauri/src/managed_agents/mod.rs index ad112f9ff9..392059dfb6 100644 --- a/desktop/src-tauri/src/managed_agents/mod.rs +++ b/desktop/src-tauri/src/managed_agents/mod.rs @@ -48,13 +48,27 @@ pub(crate) use team_repair::team_persona_key; mod teams; mod types; -// Shared guard for tests that mutate or read process-global PATH. +// Shared lock for tests that call `lock_path_mutex` or `lock_env_mutex`. +// Both helpers delegate here so any two tests using either helper are mutually +// exclusive with each other. Tests in other modules that maintain their own +// independent locks (app_state_tests, agent_config_tests, reader_tests) are +// NOT in this domain and are not covered by this mutex. #[cfg(test)] -static PATH_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(()); +static PROCESS_ENV_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(()); +// Acquires the shared process-env lock. Call from any test in this module that +// reads, writes, or removes a process-global environment variable (including PATH). #[cfg(test)] pub(crate) fn lock_path_mutex() -> std::sync::MutexGuard<'static, ()> { - PATH_MUTEX.lock().unwrap_or_else(|e| e.into_inner()) + PROCESS_ENV_MUTEX.lock().unwrap_or_else(|e| e.into_inner()) +} + +// Delegates to the same lock as `lock_path_mutex`. Tests using either helper +// are mutually exclusive with each other; PATH and env-key mutations that go +// through these helpers cannot race. +#[cfg(test)] +pub(crate) fn lock_env_mutex() -> std::sync::MutexGuard<'static, ()> { + PROCESS_ENV_MUTEX.lock().unwrap_or_else(|e| e.into_inner()) } pub use backend::*; diff --git a/desktop/src-tauri/src/managed_agents/readiness.rs b/desktop/src-tauri/src/managed_agents/readiness.rs index 9af3c989f4..88cc7884c4 100644 --- a/desktop/src-tauri/src/managed_agents/readiness.rs +++ b/desktop/src-tauri/src/managed_agents/readiness.rs @@ -269,6 +269,20 @@ fn resolve_effective_agent_env_with_def( ); env.extend(user_env); + // Single harness-agnostic effort authority (PR #4625): resolve effective + // effort over the canonical column AND all env tiers, emit one destination + // key. Runs AFTER the layer stack so launch, remote deploy, and the restart + // snapshot agree — no double authority, no foreign key, no badge disagreement. + super::config_bridge::effort::apply_launch_effort( + &mut env, + record, + runtime, + personas, + &global.env_vars, + harness_def.as_deref(), + &baked_build_env(), + ); + // Buzz shared compute is a native Buzz provider. Translate it to buzz-agent's // OpenAI-compatible transport only in the effective runtime environment. #[cfg(feature = "mesh-llm")] @@ -1049,6 +1063,8 @@ mod tests { default_env: &[], supports_acp_native_config: false, thinking_env_var: None, + effort_normalization: None, + effort_accepted_values: None, max_tokens_env_var: None, context_limit_env_var: None, max_rounds_env_var: None, @@ -1241,6 +1257,8 @@ mod tests { default_env: &[], supports_acp_native_config: false, thinking_env_var: None, + effort_normalization: None, + effort_accepted_values: None, max_tokens_env_var: None, context_limit_env_var: None, max_rounds_env_var: None, @@ -1681,56 +1699,10 @@ mod tests { })); } - // ── OpenRouter readiness ───────────────────────────────────────────── - - #[test] - fn buzz_agent_openrouter_with_all_fields_is_ready() { - let env = make_env( - "buzz-agent", - env_with(&[ - ("BUZZ_AGENT_PROVIDER", "openrouter"), - ("BUZZ_AGENT_MODEL", "anthropic/claude-sonnet-4"), - ("OPENROUTER_API_KEY", "sk-or-test-key"), - ]), - ); - let result = agent_readiness(&env); - assert!( - result.is_ready(), - "openrouter with all fields should be ready" - ); - } - - #[test] - fn buzz_agent_openrouter_missing_key_returns_not_ready() { - let env = make_env( - "buzz-agent", - env_with(&[ - ("BUZZ_AGENT_PROVIDER", "openrouter"), - ("BUZZ_AGENT_MODEL", "anthropic/claude-sonnet-4"), - ]), - ); - let result = agent_readiness(&env); - assert!(!result.is_ready()); - assert!(result.requirements().contains(&Requirement::EnvKey { - key: "OPENROUTER_API_KEY".to_string() - })); - } - #[test] - fn buzz_agent_openrouter_with_provider_model_fallback_is_ready() { - let env = make_env( - "buzz-agent", - env_with(&[ - ("BUZZ_AGENT_PROVIDER", "openrouter"), - ("OPENROUTER_MODEL", "google/gemini-2.5-flash"), - ("OPENROUTER_API_KEY", "sk-or-test-key"), - ]), - ); - let result = agent_readiness(&env); - assert!( - result.is_ready(), - "OPENROUTER_MODEL fallback should satisfy model requirement" - ); - } + // buzz-agent OpenRouter readiness tests live in a sibling file so this + // module stays under the desktop file-size ratchet. + #[path = "openrouter_tests.rs"] + mod openrouter_tests; } // Goose file-config-aware requirement tests live in a sibling file so this diff --git a/desktop/src-tauri/src/managed_agents/readiness/tests/openrouter_tests.rs b/desktop/src-tauri/src/managed_agents/readiness/tests/openrouter_tests.rs new file mode 100644 index 0000000000..73b3fcda4b --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/readiness/tests/openrouter_tests.rs @@ -0,0 +1,57 @@ +//! buzz-agent OpenRouter readiness tests, split from `readiness.rs`'s `tests` +//! module so that file stays under the desktop file-size ratchet. +//! +//! Declared as a child of `mod tests` via `#[path]`, so `use super::*` resolves +//! against that module and reaches its `make_env`/`env_with` helpers. + +use super::*; + +#[test] +fn buzz_agent_openrouter_with_all_fields_is_ready() { + let env = make_env( + "buzz-agent", + env_with(&[ + ("BUZZ_AGENT_PROVIDER", "openrouter"), + ("BUZZ_AGENT_MODEL", "anthropic/claude-sonnet-4"), + ("OPENROUTER_API_KEY", "sk-or-test-key"), + ]), + ); + let result = agent_readiness(&env); + assert!( + result.is_ready(), + "openrouter with all fields should be ready" + ); +} + +#[test] +fn buzz_agent_openrouter_missing_key_returns_not_ready() { + let env = make_env( + "buzz-agent", + env_with(&[ + ("BUZZ_AGENT_PROVIDER", "openrouter"), + ("BUZZ_AGENT_MODEL", "anthropic/claude-sonnet-4"), + ]), + ); + let result = agent_readiness(&env); + assert!(!result.is_ready()); + assert!(result.requirements().contains(&Requirement::EnvKey { + key: "OPENROUTER_API_KEY".to_string() + })); +} + +#[test] +fn buzz_agent_openrouter_with_provider_model_fallback_is_ready() { + let env = make_env( + "buzz-agent", + env_with(&[ + ("BUZZ_AGENT_PROVIDER", "openrouter"), + ("OPENROUTER_MODEL", "google/gemini-2.5-flash"), + ("OPENROUTER_API_KEY", "sk-or-test-key"), + ]), + ); + let result = agent_readiness(&env); + assert!( + result.is_ready(), + "OPENROUTER_MODEL fallback should satisfy model requirement" + ); +} diff --git a/desktop/src-tauri/src/managed_agents/runtime.rs b/desktop/src-tauri/src/managed_agents/runtime.rs index de1bf97b4f..b10271ea1c 100644 --- a/desktop/src-tauri/src/managed_agents/runtime.rs +++ b/desktop/src-tauri/src/managed_agents/runtime.rs @@ -2,7 +2,7 @@ use std::collections::HashMap; use tauri::{AppHandle, Manager}; -use super::agent_env::{build_buzz_agent_provider_defaults, idle_pool_sleep_env}; +use super::agent_env::idle_pool_sleep_env; use crate::{ managed_agents::{ @@ -14,7 +14,7 @@ use crate::{ util::now_iso, }; -use super::claude_config::{apply_claude_model_env, apply_effort_env}; +use super::claude_config::apply_claude_model_env; mod path; pub(in crate::managed_agents) use path::build_augmented_path; pub(crate) use path::{compose_path_entries, should_skip_claude_executable, should_use_inherited}; @@ -225,23 +225,14 @@ pub fn build_managed_agent_summary( } }; - // Restart badge: the running process stamped the effective spawn config - // it was launched with; recompute a prospective one from current disk - // state and report every differing field. Only the tracked live pair for - // THIS workspace can drift — stopped agents spawn fresh, adopted - // (runtime_pid-only) processes have no stamp to compare, and pairs running - // for other communities are judged in their own community (comparing them - // against this workspace's relay would flag a spurious restart on every - // community switch). - // - // Adapter-availability drift (codex only) contributes its own synthetic - // entry, so an out-of-band adapter change (manual npm install/downgrade) - // that Phase-1 auto-restart doesn't cover still shows the user what moved. - // The cache is read-only here — no subprocess is spawned. - // - // Global config drives both the prospective snapshot and the descriptor - // env layering below — the caller loads it once and passes it in, so - // list-style callers pay one disk read per call rather than one per record. + // Restart badge: the running process stamped its effective spawn config; + // recompute a prospective one from current disk state and report every + // differing field. Only the tracked live pair for THIS workspace can drift + // (stopped agents spawn fresh; adopted processes have no stamp; other- + // community pairs are judged in their own community). Adapter drift + // (codex only) contributes a synthetic entry for out-of-band npm changes. + // Global config drives both snapshot and descriptor env layering; the + // caller loads it once so list callers pay one disk read per call. // The prospective side is computed only for a tracked pair: an unstamped // agent has nothing to compare against. @@ -397,6 +388,47 @@ pub(crate) fn configure_runtime_cli( } } +/// Proof token for the effort-application outer binding. `#[must_use]`; +/// makes `let effort = apply_effort_to_spawn_command(…)` a compile-time +/// requirement — deleting the binding is a compile error because +/// `spawn_with_effort_proof` consumes it by value. +/// +/// The private field prevents any crate-local code from constructing +/// `EffortApplied` directly (same shape as `RecordFieldsApplied(())`), so +/// the only way to obtain a token is to call `apply_effort_to_spawn_command`. +#[must_use] +pub(crate) struct EffortApplied(()); + +/// Apply effort env to an agent spawn command. Called by `spawn_agent_child` +/// (production) and `effort_cmd_tests` (test seam). Inner-seam: removing +/// `apply_spawn_effort_env` below turns the production-sequence tests RED. +/// Outer-seam: the returned token is consumed by `spawn_with_effort_proof`; +/// deleting this call leaves `effort` undefined at the spawn site. +pub(crate) fn apply_effort_to_spawn_command( + cmd: &mut std::process::Command, + record: &crate::managed_agents::types::ManagedAgentRecord, + runtime: Option<&crate::managed_agents::discovery::KnownAcpRuntime>, + personas: &[crate::managed_agents::types::AgentDefinition], + persona_id: Option<&str>, + global_env: &std::collections::BTreeMap, + baked_env: &std::collections::BTreeMap, +) -> EffortApplied { + super::config_bridge::effort::apply_spawn_effort_env( + cmd, record, runtime, personas, persona_id, global_env, baked_env, + ); + EffortApplied(()) +} + +/// Spawn the agent command, consuming the `EffortApplied` proof token. +/// Deleting `apply_effort_to_spawn_command` from `spawn_agent_child` leaves +/// `effort` undefined here — a compile error CI catches before any test runs. +pub(crate) fn spawn_with_effort_proof( + cmd: &mut std::process::Command, + _effort: EffortApplied, +) -> std::io::Result { + cmd.spawn() +} + /// Spawn an agent process without holding any locks on records or runtimes. /// Returns the child process and log path on success. The caller is responsible /// for updating `ManagedAgentRecord` fields and inserting into the runtimes map. @@ -552,23 +584,16 @@ pub fn spawn_agent_child( // ── Readiness check: set setup-payload if agent is not ready ───────────── // - // Build the effective env the agent would have at start-time, run the - // readiness predicate, and if anything is missing, serialize the payload - // into BUZZ_ACP_SETUP_PAYLOAD. buzz-acp detects this env var on startup - // and enters the minimal setup-listener mode instead of the agent pool. + // Build the effective env, run the readiness predicate, and serialize any + // missing requirements into BUZZ_ACP_SETUP_PAYLOAD. buzz-acp enters + // setup-listener mode when this env var is present. // - // SECURITY: BUZZ_ACP_SETUP_PAYLOAD is in RESERVED_ENV_KEYS so user env - // cannot set it, but we also explicitly remove it after writing user env - // to guard against the parent-process environment. We then set it only - // when desktop has computed NotReady — the desktop is the sole readiness - // source and buzz-acp only transports the payload. + // SECURITY: BUZZ_ACP_SETUP_PAYLOAD is in RESERVED_ENV_KEYS (user env cannot + // set it). We also remove it after writing user env as a parent-process guard, + // then set it only when desktop computes NotReady — desktop is the sole source. // - // The JSON format mirrors `setup_mode::SetupPayload` in buzz-acp: - // { "agent_name": "...", "agent_pubkey": "...", "requirements": [{ "surface": "...", ... }] } - // - // `spawned_setup_mode` is captured outside the block so it can be stamped - // on `ManagedAgentProcess` — used by `install_acp_runtime` to target only - // stuck agents for auto-restart. + // `spawned_setup_mode` is captured outside the block to stamp + // `ManagedAgentProcess` (used by `install_acp_runtime` for auto-restart). let spawned_setup_mode; { use crate::managed_agents::readiness::EffectiveAgentEnv; @@ -741,7 +766,18 @@ pub fn spawn_agent_child( &mut command, resolve_session_title(record.display_name.as_deref(), &record.name), ); - build_buzz_agent_provider_defaults(&mut command); + // Strip all known effort keys and emit exactly one projected key. Command + // inherits the parent env — the returned EffortApplied token is consumed + // by spawn_with_effort_proof below; deleting this call is a compile error. + let effort = apply_effort_to_spawn_command( + &mut command, + record, + runtime_meta, + &personas, + record.persona_id.as_deref(), + &global.env_vars, + &super::agent_env::baked_build_env(), + ); if let Some(meta) = runtime_meta { for (key, value) in runtime_metadata_env_vars( meta.model_env_var, @@ -812,14 +848,6 @@ pub fn spawn_agent_child( let acp_session_policy = super::apply_app_acp_session_policy_env(app, &mut command); crate::build_identity::apply_demo_config_home(&mut command)?; - // B5: carry persisted effort; harness resolves thought_level configId at first session. - // Written AFTER descriptor.env so the canonical persisted value wins over any - // user-supplied BUZZ_ACP_EFFORT_LEVEL entry, mirroring the A1 model-authority pattern - // (ANTHROPIC_MODEL is applied post-loop for the same reason). When effort_level is - // None there is no canonical value to assert, so env passthrough stands — user env - // legitimately seeds startup effort in that case. - apply_effort_env(&mut command, record.effort_level.as_deref()); - // A1: for local claude agents, ANTHROPIC_MODEL is the single startup model authority. // BUZZ_ACP_MODEL is removed (live ACP switches only; two authorities in the same env // would be ambiguous). @@ -849,10 +877,8 @@ pub fn spawn_agent_child( .env("BUZZ_MANAGED_AGENT", current_instance_id(app)) .env("BUZZ_MANAGED_AGENT_START_NONCE", &start_nonce); - // Stamp the effective spawn config from the values that populated the - // `Command` above, BEFORE spawning. Re-resolving after `spawn()` would let - // a persona/harness/global edit landing in between stamp the NEW config - // onto a child running the OLD one, silently suppressing the badge. + // Stamp spawn config from values above, BEFORE spawning — a post-spawn + // re-resolve races config edits and would stamp the wrong values. let spawn_config = super::spawn_snapshot::SpawnConfigSnapshot::from_inputs( super::spawn_snapshot::SpawnConfigInputs { record, @@ -867,8 +893,7 @@ pub fn spawn_agent_child( }, ); - // Spawn the harness in its own process group so we can kill the entire - // tree (harness + MCP servers + agent subprocesses) on shutdown. + // Spawn in its own process group (Unix) or with CREATE_NO_WINDOW (Windows). #[cfg(unix)] { use std::os::unix::process::CommandExt; @@ -884,7 +909,7 @@ pub fn spawn_agent_child( command.creation_flags(CREATE_NO_WINDOW); } - let child = command.spawn().map_err(|error| { + let child = spawn_with_effort_proof(&mut command, effort).map_err(|error| { format!( "failed to spawn `{}` for agent {}: {error}", resolved_acp_command.display(), @@ -892,14 +917,8 @@ pub fn spawn_agent_child( ) })?; - // Stamp the adapter availability for runtimes with a version gate (codex - // only). The summary builder compares this against the current cached value - // to detect out-of-band adapter changes after spawn (Phase-2 badge fallback). - // Non-codex runtimes get `None` — nothing changes for them. - // When the cache is cold (e.g. Doctor just installed and cleared the cache), - // `adapter_availability_cached()` returns `None`, so the stamp is `None` and - // the drift check is skipped until discovery warms the cache — preventing a - // false restart badge immediately after auto-restart. + // Codex: stamp adapter availability for the Phase-2 badge drift check. + // Cold cache returns `None` → drift check skipped until discovery warms it. let spawned_adapter_availability = if runtime_meta.is_some_and(|r| r.id == "codex") { super::adapter_availability_cached() } else { diff --git a/desktop/src-tauri/src/managed_agents/spawn_snapshot.rs b/desktop/src-tauri/src/managed_agents/spawn_snapshot.rs index c201c0a8a5..810ad439f2 100644 --- a/desktop/src-tauri/src/managed_agents/spawn_snapshot.rs +++ b/desktop/src-tauri/src/managed_agents/spawn_snapshot.rs @@ -31,7 +31,6 @@ use std::collections::BTreeMap; use serde::Serialize; use super::{ - claude_config::EFFORT_LEVEL_ENV_VAR, effective_config::{resolve_effective_config, EffectiveConfigResult}, known_acp_runtime, normalize_agent_args, persona_events::preview_prospective_persona_snapshot, @@ -134,13 +133,16 @@ pub(crate) struct SpawnConfigSnapshot { pub max_turn_duration_seconds: Option, pub parallelism: u32, /// The startup effort the harness will actually apply, resolved by - /// [`effective_effort`]: the persisted canonical `record.effort_level` when - /// present, else the user-seeded `BUZZ_ACP_EFFORT_LEVEL` from the layered - /// env. This is the *sole* representation of effort in the snapshot — the - /// key is stripped from `env` (see `from_inputs`) so an authority handoff - /// that leaves the effective value unchanged (canonical `low` replacing a - /// user env `low`, or the reverse) produces no spurious drift entry, and an - /// env-only edit still surfaces as exactly one `effort_level` entry. + /// [`effective_effort`]: the single effort key the harness-agnostic + /// projection left in `descriptor.env` under the runtime's destination key. + /// This is the *sole* representation of the effective effort in the + /// snapshot: the projection's destination key is stripped from `env` (see + /// `from_inputs`) so an authority handoff that leaves the effective value + /// unchanged produces no spurious drift entry, and an effort edit the + /// projection consumed surfaces as exactly one `effort_level` entry. For an + /// unknown/custom runtime the projection consumes nothing beyond the + /// sentinel, so any other effort-looking key the child receives stays in + /// `env` as ordinary state and diffs normally. pub effort_level: Option, /// The effective ACP session policy this launch applies (`channel` or /// `thread`). The harness reads `BUZZ_ACP_SESSION_POLICY` only at launch, so @@ -152,20 +154,28 @@ pub(crate) struct SpawnConfigSnapshot { pub session_policy: String, } -/// The startup effort a spawn would actually apply, mirroring `apply_effort_env` -/// exactly: the persisted canonical `record.effort_level` wins, and only when it -/// is absent does a user-supplied `BUZZ_ACP_EFFORT_LEVEL` from the layered env -/// seed startup effort. This is the resolver input for the snapshot's single -/// `effort_level` representation; the same precedence runs at spawn time in -/// `runtime.rs`, so badge and process can never disagree. -pub(crate) fn effective_effort( - record: &ManagedAgentRecord, - descriptor_env: &BTreeMap, -) -> Option { - record - .effort_level - .clone() - .or_else(|| descriptor_env.get(EFFORT_LEVEL_ENV_VAR).cloned()) +/// The startup effort a spawn actually applied, read from the single effort key +/// the harness-agnostic projection left in `descriptor.env`. +/// +/// The projection (`config_bridge::effort`) ran inside the descriptor resolver, +/// resolving the effective value over the canonical column and every env tier, +/// then reducing the env to exactly one effort key under the runtime's +/// destination key (`effort_dest_key`). Reading that key here means the badge +/// compares precisely what launched — no separate precedence to drift from the +/// spawn path, and an invalid canonical that fell through to an inherited tier +/// is reflected as the inherited value, not the raw column. +pub(crate) fn effective_effort(descriptor: &EffectiveHarnessDescriptor) -> Option { + let runtime = known_acp_runtime(&descriptor.command); + let dest_key = super::config_bridge::effort::effort_dest_key(runtime); + // Read case-insensitively (exact-first) so a mixed-case sentinel a custom + // runtime passed through (the projection uses an EMPTY suppress set, so a + // user-set `buzz_acp_effort_level` survives into `descriptor.env` and the + // child reads it as `BUZZ_ACP_EFFORT_LEVEL` on Windows) is captured here. + // The read must match the snapshot strip, which is also case-insensitive: + // if the read were exact-case it would miss the mixed-case sentinel, the + // strip would still remove it, and the value would land in neither + // `snapshot.env` nor `effort_level` — producing no restart diff on an edit. + super::config_bridge::effort::get_ci(&descriptor.env, dest_key).cloned() } impl SpawnConfigSnapshot { @@ -193,14 +203,27 @@ impl SpawnConfigSnapshot { .unwrap_or("") .to_string(), // Effort has ONE representation in the snapshot: `effort_level` - // below, always holding `effective_effort`. Stripping the env key - // here means a canonical/user-env authority handoff at the same - // value is a no-op (no phantom `env.BUZZ_ACP_EFFORT_LEVEL` add or - // remove) and an env-only effort edit surfaces as exactly one - // `effort_level` entry rather than a duplicate under `env.`. + // below, always holding the projected effective value. The keys + // stripped here mirror EXACTLY what the launch projection suppressed + // for this runtime (`snapshot_suppress_keys`): a known runtime swept + // every effort key to its single destination key, so the full set is + // stripped (a no-op beyond that dest key); an unknown/custom runtime + // used an empty suppress set (external-review-#2 pass-through), so + // only the ACP-startup sentinel is stripped and every other + // effort-looking key the child actually receives (e.g. a hand-rolled + // `GOOSE_THINKING_EFFORT`) stays as ordinary env — an edit to it must + // diff the snapshot and fire the restart badge. Stripping is + // ASCII-case-insensitive to match the projection's `apply`. env: { let mut env = descriptor.env.clone(); - env.remove(EFFORT_LEVEL_ENV_VAR); + let suppress = super::config_bridge::effort::snapshot_suppress_keys( + known_acp_runtime(&descriptor.command), + ); + env.retain(|k, _| { + !suppress + .iter() + .any(|suppressed| k.eq_ignore_ascii_case(suppressed)) + }); env }, relay_url: relay_url.to_string(), @@ -230,10 +253,10 @@ impl SpawnConfigSnapshot { // effective value — that is correct, it is what actually runs. parallelism: super::effective_parallelism(&descriptor.command, record.parallelism), // Sole effort representation — see the field doc and the `env` - // strip above. Resolver reads the record's canonical value and the - // raw descriptor env (before the strip), so a user-seeded env value - // is preserved as the effective effort when no canonical is set. - effort_level: effective_effort(record, &descriptor.env), + // strip above. Reads the single projected effort key the descriptor + // resolver left in `descriptor.env`, so the badge compares exactly + // what launched regardless of which tier supplied the value. + effort_level: effective_effort(descriptor), session_policy: session_policy.as_str().to_string(), } } diff --git a/desktop/src-tauri/src/managed_agents/spawn_snapshot/tests_ext.rs b/desktop/src-tauri/src/managed_agents/spawn_snapshot/tests_ext.rs index 03bba90cff..b5ee8d4522 100644 --- a/desktop/src-tauri/src/managed_agents/spawn_snapshot/tests_ext.rs +++ b/desktop/src-tauri/src/managed_agents/spawn_snapshot/tests_ext.rs @@ -27,86 +27,112 @@ fn effort_set_then_cleared_round_trips_to_no_effort_projection() { } #[test] -fn shadowed_user_env_effort_edit_under_canonical_is_empty_diff() { - // Canonical `high` shadows the user env seed. Editing that seed low→medium - // changes nothing effective (canonical wins and the env key is stripped), - // so the projections are identical and no badge lights. - let mut low_env = record_with_env_effort("low"); - low_env.effort_level = Some("high".into()); - let mut medium_env = record_with_env_effort("medium"); - medium_env.effort_level = Some("high".into()); +fn canonical_edit_under_record_native_env_is_empty_diff() { + // For Goose, the record-native env key `GOOSE_THINKING_EFFORT` outranks the + // canonical column (CLEAR authority order). With a record-native `low` + // present, editing the shadowed canonical high→medium changes nothing + // effective, so the projections are identical and no badge lights. + let mut high_col = record_with_env_effort("low"); + high_col.effort_level = Some("high".into()); + let mut medium_col = record_with_env_effort("low"); + medium_col.effort_level = Some("medium".into()); assert_eq!( - snap(&low_env), - snap(&medium_env), - "editing a canonical-shadowed user env must not badge" + snap(&high_col), + snap(&medium_col), + "editing a record-native-env-shadowed canonical must not badge" ); } #[test] -fn clearing_canonical_reveals_env_fallback_and_creates_a_diff() { - // Canonical `high` over a user env seed `low`: clearing the canonical drops - // the effective effort to the env fallback `low`, a real change that badges. - let mut canonical = record_with_env_effort("low"); - canonical.effort_level = Some("high".into()); - let env_only = record_with_env_effort("low"); +fn clearing_record_native_env_reveals_canonical_and_creates_a_diff() { + // Record-native env `low` shadows canonical `high`: removing the record env + // key drops resolution to the canonical `high`, a real change that badges. + let mut env_over_canonical = record_with_env_effort("low"); + env_over_canonical.effort_level = Some("high".into()); + let mut canonical_only = goose_record(); + canonical_only.effort_level = Some("high".into()); assert_ne!( - snap(&canonical), - snap(&env_only), - "clearing canonical must reveal the env fallback and badge" + snap(&env_over_canonical), + snap(&canonical_only), + "removing the record-native env must reveal the canonical and badge" ); } -// ── B5 effort: single canonical representation ─────────────────────────── +// ── Effort: single canonical representation ────────────────────────────── // // `effective_effort` and the snapshot's `effort_level` field are the sole -// carrier of startup effort. `BUZZ_ACP_EFFORT_LEVEL` is stripped from the -// snapshot `env` so an authority handoff at an unchanged effective value -// (canonical replacing a user-env seed, or the reverse) raises no spurious -// restart badge, while a genuine effort change surfaces exactly once. +// carrier of startup effort. Every effort key is stripped from the snapshot +// `env` so an authority handoff at an unchanged effective value raises no +// spurious restart badge, while a genuine effort change surfaces exactly once. -/// Look up the `env.BUZZ_ACP_EFFORT_LEVEL` leaf of a canonical snapshot, if any. +/// Look up the `env.GOOSE_THINKING_EFFORT` leaf of a canonical snapshot, if any +/// (the record()'s runtime is Goose, so this is its destination key). fn effort_env_leaf(canonical: &serde_json::Value) -> Option<&serde_json::Value> { canonical .get("env") - .and_then(|env| env.get("BUZZ_ACP_EFFORT_LEVEL")) + .and_then(|env| env.get("GOOSE_THINKING_EFFORT")) } -/// A record whose user env seeds `BUZZ_ACP_EFFORT_LEVEL` (the pre-canonical -/// authority: no persisted `effort_level`, effort comes from user env_vars). +/// A Goose record whose record-native env seeds `GOOSE_THINKING_EFFORT` (the +/// top authority tier for Goose: effort comes from user env_vars, no column). +/// Pins `runtime = "goose"` so the effective command resolves to Goose and +/// `GOOSE_THINKING_EFFORT` is the record-*native* key — without it the record +/// falls back to the default `buzz-agent` runtime, for which that key is a +/// foreign env alias the projection suppresses rather than an authority tier. fn record_with_env_effort(value: &str) -> ManagedAgentRecord { let mut rec = record(); + rec.runtime = Some("goose".into()); rec.env_vars - .insert("BUZZ_ACP_EFFORT_LEVEL".into(), value.into()); + .insert("GOOSE_THINKING_EFFORT".into(), value.into()); + rec +} + +/// A Goose record with no effort env: the canonical column is the authority. +fn goose_record() -> ManagedAgentRecord { + let mut rec = record(); + rec.runtime = Some("goose".into()); rec } #[test] -fn effective_effort_prefers_persisted_canonical_over_user_env() { - // Canonical wins, mirroring spawn's `apply_effort_env` (written after the - // user env layer). The env value is ignored when a canonical is present. - let mut rec = record(); - rec.effort_level = Some("high".into()); - let env = BTreeMap::from([("BUZZ_ACP_EFFORT_LEVEL".to_string(), "low".to_string())]); - assert_eq!(effective_effort(&rec, &env).as_deref(), Some("high")); +fn effective_effort_reads_the_projected_key_for_the_runtime() { + // The projection reduced the descriptor env to one effort key under the + // runtime's destination key. `effective_effort` reads exactly that key. + // A Goose descriptor carries `GOOSE_THINKING_EFFORT`. + let descriptor = EffectiveHarnessDescriptor { + command: "goose".into(), + args: vec![], + env: BTreeMap::from([("GOOSE_THINKING_EFFORT".to_string(), "high".to_string())]), + }; + assert_eq!(effective_effort(&descriptor).as_deref(), Some("high")); } #[test] -fn effective_effort_falls_back_to_user_env_when_no_canonical() { - // No persisted canonical → the user-seeded env value is the effective - // startup effort, exactly what a spawn would leave in place. - let rec = record(); - let env = BTreeMap::from([("BUZZ_ACP_EFFORT_LEVEL".to_string(), "low".to_string())]); - assert_eq!(effective_effort(&rec, &env).as_deref(), Some("low")); +fn effective_effort_reads_acp_sentinel_for_keyless_runtime() { + // Claude/Codex/keyless-ACP descriptors carry the effective value under the + // ACP-startup sentinel, which is the destination key for a runtime with no + // native thinking-effort env var (here: the claude adapter command). + let descriptor = EffectiveHarnessDescriptor { + command: "claude-code-acp".into(), + args: vec![], + env: BTreeMap::from([("BUZZ_ACP_EFFORT_LEVEL".to_string(), "low".to_string())]), + }; + assert_eq!(effective_effort(&descriptor).as_deref(), Some("low")); } #[test] -fn effective_effort_is_none_without_canonical_or_env() { - assert_eq!(effective_effort(&record(), &BTreeMap::new()), None); +fn effective_effort_is_none_without_a_projected_key() { + let descriptor = EffectiveHarnessDescriptor { + command: "goose".into(), + args: vec![], + env: BTreeMap::new(), + }; + assert_eq!(effective_effort(&descriptor), None); } #[test] fn snapshot_carries_effort_in_field_not_env() { - // Always-canonicalize: a user-seeded effort reaches the snapshot ONLY as + // Always-canonicalize: a record-native effort reaches the snapshot ONLY as // the `effort_level` field; the raw env key is stripped so effort has one // representation, never two. let canonical = snap(&record_with_env_effort("low")); @@ -118,16 +144,42 @@ fn snapshot_carries_effort_in_field_not_env() { assert_eq!( effort_env_leaf(&canonical), None, - "BUZZ_ACP_EFFORT_LEVEL must be stripped from the snapshot env" + "GOOSE_THINKING_EFFORT must be stripped from the snapshot env" + ); +} + +#[test] +fn foreign_transport_sentinel_is_suppressed_for_goose() { + // A user-seeded `BUZZ_ACP_EFFORT_LEVEL` is a foreign transport key for a + // Goose descriptor: never an authority tier, and stripped from the snapshot + // env by the suppress set. Editing it low→medium changes nothing. + let mut low = record(); + low.env_vars + .insert("BUZZ_ACP_EFFORT_LEVEL".into(), "low".into()); + let mut medium = record(); + medium + .env_vars + .insert("BUZZ_ACP_EFFORT_LEVEL".into(), "medium".into()); + assert_eq!( + snap(&low), + snap(&medium), + "a foreign transport effort key must be suppressed for Goose and never badge" + ); + let canonical = snap(&low); + assert_eq!( + canonical + .get("env") + .and_then(|env| env.get("BUZZ_ACP_EFFORT_LEVEL")), + None, + "the foreign sentinel must be stripped from the snapshot env" ); } #[test] fn equal_value_effort_authority_handoff_env_to_canonical_is_no_op() { - // User env `low` (no canonical) → persisted canonical `low` while the env - // seed remains: the effective effort is `low` either way, so a restart - // would change nothing. Old raw-env snapshots would have shown drift; the - // single canonical representation makes the projections identical. + // Record-native env `low` (no column) → canonical column `low` while the + // record env remains: the effective effort is `low` either way (env wins, + // but the value is identical), so a restart would change nothing. let env_authority = record_with_env_effort("low"); let mut canonical_authority = record_with_env_effort("low"); canonical_authority.effort_level = Some("low".into()); @@ -138,30 +190,17 @@ fn equal_value_effort_authority_handoff_env_to_canonical_is_no_op() { ); } -#[test] -fn equal_value_effort_authority_handoff_canonical_to_env_is_no_op() { - // The reverse direction: canonical `low` (env seed present) → env `low` - // only (canonical cleared). Effective effort stays `low`; no badge. - let mut canonical_authority = record_with_env_effort("low"); - canonical_authority.effort_level = Some("low".into()); - let env_authority = record_with_env_effort("low"); - assert_eq!( - snap(&canonical_authority), - snap(&env_authority), - "clearing the canonical while the env seed holds the same value must not badge" - ); -} - #[test] fn env_only_effort_edit_changes_effort_level_not_env() { - // An env-only effort edit (no canonical) moves the single `effort_level` - // representation and never reintroduces an `env.BUZZ_ACP_EFFORT_LEVEL` - // leaf, so the diff names `effort_level` once rather than duplicating it. + // A record-native env effort edit (no column) moves the single + // `effort_level` representation and never reintroduces a + // `env.GOOSE_THINKING_EFFORT` leaf, so the diff names `effort_level` once + // rather than duplicating it. let low = snap(&record_with_env_effort("low")); let high = snap(&record_with_env_effort("high")); assert_ne!( low, high, - "an env-only effort edit must change the snapshot" + "a record-native effort edit must change the snapshot" ); assert_eq!( low.get("effort_level").and_then(|v| v.as_str()), @@ -188,6 +227,163 @@ fn canonical_effort_edit_changes_snapshot() { ); } +/// A custom-command record whose runtime matches no known ACP runtime, so the +/// launch projection suppresses ONLY its own ACP sentinel (external-review-#2 +/// pass-through, r5): every foreign effort key survives untouched and the child +/// receives its raw effort env. +fn custom_command_record() -> ManagedAgentRecord { + let mut rec = record(); + rec.agent_command_override = Some("/opt/custom/my-agent".into()); + rec +} + +#[test] +fn custom_runtime_effort_env_stays_in_snapshot_and_diffs() { + // Regression (external review, Carl): for an unknown/custom runtime the + // launch projection strips only its own ACP sentinel, so the child receives + // the raw `GOOSE_THINKING_EFFORT` from the wrapper's env. The snapshot must + // retain that key as ordinary env — the projection consumed nothing into + // `effort_level` (its dest key, the ACP sentinel, is absent) — so an edit to + // it diffs the snapshot and fires the restart badge. The prior full strip + // erased the key from both places, producing NO restart diff on an effort + // edit and leaving the running agent on stale effort. + let mut high = custom_command_record(); + high.env_vars + .insert("GOOSE_THINKING_EFFORT".into(), "high".into()); + let canonical = snap(&high); + assert_eq!( + canonical + .get("env") + .and_then(|env| env.get("GOOSE_THINKING_EFFORT")) + .and_then(|v| v.as_str()), + Some("high"), + "a custom runtime's effort env must remain in the snapshot as ordinary env" + ); + assert_eq!( + canonical.get("effort_level").and_then(|v| v.as_str()), + None, + "the custom sentinel dest key is absent, so effort_level captures nothing" + ); + + let mut low = custom_command_record(); + low.env_vars + .insert("GOOSE_THINKING_EFFORT".into(), "low".into()); + assert_ne!( + snap(&low), + canonical, + "editing a custom runtime's effort env must trip the restart badge" + ); +} + +#[test] +fn known_runtime_still_strips_native_effort_env_from_snapshot() { + // The counter-case pinning the scoping: for a KNOWN runtime the full sweep + // still applies, so `GOOSE_THINKING_EFFORT` reaches the snapshot only as the + // single `effort_level` field — never as a phantom `env` entry alongside it. + let canonical = snap(&record_with_env_effort("high")); + assert_eq!( + effort_env_leaf(&canonical), + None, + "a known runtime must still strip its native effort key from the snapshot env" + ); + assert_eq!( + canonical.get("effort_level").and_then(|v| v.as_str()), + Some("high"), + "the known runtime's effort must land solely in the effort_level field" + ); +} + +#[test] +fn custom_runtime_mixed_case_sentinel_is_captured_not_lost() { + // Regression (external review, Carl, P2): for an unknown/custom runtime a + // user-set mixed-case `buzz_acp_effort_level` (no column) must not vanish. + // The launch projection now reconciles it — stripping the mixed-case + // spelling and re-emitting the pass-through value under the canonical + // `BUZZ_ACP_EFFORT_LEVEL` (see `effort_tests:: + // unknown_runtime_collapses_mixed_case_sentinel_to_canonical_when_no_column`) + // — so `descriptor.env` carries exactly one canonical sentinel. The snapshot + // captures it into `effort_level` and strips it from `env`. Before the r4/r5 + // fixes the mixed-case key survived while the exact-case read missed it, so + // the value vanished from BOTH fields and an edit produced no restart diff. + let mut high = custom_command_record(); + high.env_vars + .insert("buzz_acp_effort_level".into(), "high".into()); + let canonical = snap(&high); + assert_eq!( + canonical.get("effort_level").and_then(|v| v.as_str()), + Some("high"), + "a mixed-case pass-through sentinel must be captured into effort_level" + ); + assert_eq!( + canonical + .get("env") + .and_then(|env| env.get("buzz_acp_effort_level")), + None, + "the sentinel is the projection's dest key and is stripped from env once represented" + ); + + // The mutation pin: editing the mixed-case sentinel must trip the badge. + // Reverting the fix (exact-case read + case-insensitive strip, or an empty + // unknown-runtime suppress set) makes both snapshots carry + // `effort_level = null` with the key stripped, so they compare equal and + // this assertion fails. + let mut low = custom_command_record(); + low.env_vars + .insert("buzz_acp_effort_level".into(), "low".into()); + assert_ne!( + snap(&low), + canonical, + "editing a mixed-case custom-runtime sentinel must trip the restart badge" + ); +} + +#[test] +fn custom_runtime_canonical_column_wins_over_mixed_case_sentinel() { + // The with-canonical-column collision case Carl asked for, verified at the + // SNAPSHOT here and — decisively — at the projection/descriptor seam in + // `effort_tests::unknown_runtime_column_wins_over_mixed_case_sentinel`. The + // projection strips every case variant of the sentinel before emitting the + // column value, so `descriptor.env` carries exactly `BUZZ_ACP_EFFORT_LEVEL= + // ` and the child receives the column value on every platform (no + // lowercase variant survives for Windows to case-fold over the canonical + // key). This snapshot therefore reads the same truth the child gets: the + // column wins `effort_level` and both case variants are absent from `env`. + let mut high_col = custom_command_record(); + high_col.effort_level = Some("high".into()); + high_col + .env_vars + .insert("buzz_acp_effort_level".into(), "low".into()); + let canonical = snap(&high_col); + assert_eq!( + canonical.get("effort_level").and_then(|v| v.as_str()), + Some("high"), + "the canonical column wins effort_level over the pass-through sentinel" + ); + let env = canonical.get("env").expect("snapshot has an env object"); + assert_eq!( + env.get("BUZZ_ACP_EFFORT_LEVEL"), + None, + "the projection-emitted canonical sentinel is stripped from env" + ); + assert_eq!( + env.get("buzz_acp_effort_level"), + None, + "the user's mixed-case sentinel duplicate is stripped case-insensitively" + ); + + // Editing the authority (the column) still trips the badge. + let mut low_col = custom_command_record(); + low_col.effort_level = Some("low".into()); + low_col + .env_vars + .insert("buzz_acp_effort_level".into(), "low".into()); + assert_ne!( + snap(&low_col), + canonical, + "editing the canonical column must trip the restart badge" + ); +} + use crate::managed_agents::spawn_snapshot::{ eligible_restart_diff, prospective_spawn_config_snapshot, RestartDiffEntry, SpawnConfigSnapshot, TrackedSpawnState, diff --git a/desktop/src-tauri/src/managed_agents/types.rs b/desktop/src-tauri/src/managed_agents/types.rs index b3c9d4b53c..2620f0337f 100644 --- a/desktop/src-tauri/src/managed_agents/types.rs +++ b/desktop/src-tauri/src/managed_agents/types.rs @@ -466,8 +466,14 @@ pub struct ManagedAgentRecord { /// deserialize as `None`. #[serde(default, skip_serializing_if = "Option::is_none")] pub relay_mesh: Option, - /// Canonical Claude Code effort level. Injected as `BUZZ_ACP_EFFORT_LEVEL` at spawn - /// so the harness applies it via `session/set_config_option` at session creation. + /// Canonical, harness-agnostic startup effort level. This is the single + /// persisted effort authority: at spawn the launch projection + /// (`config_bridge::effort`) resolves the effective value over this column + /// and all env tiers, then emits it under the destination runtime's native + /// key — `GOOSE_THINKING_EFFORT` for Goose, `BUZZ_AGENT_THINKING_EFFORT` for + /// buzz-agent, or the `BUZZ_ACP_EFFORT_LEVEL` startup sentinel for + /// Claude/Codex and keyless/unknown adapters. Preserved across runtime + /// switches (invalid values skip-as-absent at projection time). #[serde(default, skip_serializing_if = "Option::is_none")] pub effort_level: Option, } @@ -656,6 +662,18 @@ pub struct AcpRuntimeCatalogEntry { pub provider_env_var: Option, /// Environment variable used to apply thinking effort, when supported. pub thinking_env_var: Option, + /// Canonical accepted effort values for this runtime, in display order. + /// Serialized from `KnownAcpRuntime::effort_normalization.canonical` for + /// runtimes with a static finite vocabulary (e.g. Goose). `None` for + /// runtimes with no canonicalization contract (buzz-agent uses a + /// provider/model catalog; Claude/Codex/unknown runtimes accept any string). + /// + /// The renderer uses this to drive choices and validation, replacing the + /// TS-side `GOOSE_EFFORT_CANONICAL_VALUES` duplicate. When non-null, the + /// `harnessNative` effort field uses this list exclusively — `off` and all + /// other valid Goose values are always present when this is Goose, so + /// `useEffortAutoClear` never incorrectly deletes a valid saved value. + pub effort_canonical_values: Option>, pub max_tokens_env_var: Option, pub context_limit_env_var: Option, pub max_rounds_env_var: Option, diff --git a/desktop/src-tauri/src/managed_agents/types/requests.rs b/desktop/src-tauri/src/managed_agents/types/requests.rs index a7b379ac83..824ca4ccf3 100644 --- a/desktop/src-tauri/src/managed_agents/types/requests.rs +++ b/desktop/src-tauri/src/managed_agents/types/requests.rs @@ -260,6 +260,16 @@ pub struct UpdateManagedAgentRequest { /// normalized server-side). #[serde(default)] pub respond_to_allowlist: Option>, + /// Absent = don't touch. `null` = clear the canonical effort column + /// (revert to inherited default). `"value"` = set the column. + /// + /// When present, persisted inside the locked update/restart transaction + /// so that an access-policy-change restart snapshots and launches the new + /// effort value rather than the old one. Uses the same + /// `apply_picker_effort_level` logic (via `apply_effort_update`) so + /// the record-scope alias sweep runs atomically with the column write. + #[serde(default, deserialize_with = "crate::util::double_option")] + pub effort_level: Option>, } #[cfg(test)] diff --git a/desktop/src/features/agents/AGENTS.md b/desktop/src/features/agents/AGENTS.md index 64509387fe..39f3a3bdef 100644 --- a/desktop/src/features/agents/AGENTS.md +++ b/desktop/src/features/agents/AGENTS.md @@ -204,21 +204,22 @@ with a TypeScript lookup table or an id comparison in a component. 14. **Thinking effort has two surfaces: a local-only WRITE control and a read-only two-facts DISPLAY.** The write control is `EffortPickerField` (`ui/EffortPickerField.tsx`), a self-contained section component mounted in - `AgentInstanceEditDialog` beside the Model block. It is direct-write, not - part of the frozen `UpdateManagedAgentInput` shape: each selection calls - `persistAgentEffortLevel` and invalidates the config-surface query, mirroring - the `setManagedAgentAutoRestart` standalone-setter precedent. Its gating and - option compute live in the pure helper `ui/effortPicker.ts` - (`effortPickerState`): the picker renders only when - `agent.backend.type === "local"` **AND** a `thought_level` `effortConfigId` - has been discovered from the running session (absent pre-first-session and - for runtimes/models without effort support). Local-only is load-bearing, not - cosmetic — the Rust command rejects non-local backends because remote effort - is set at deploy time via `policy_env`. Because it reads its inputs from the - config surface the dialog already fetches (`useAgentConfigSurface`) and owns - its own mutation, it does **not** thread new props through the dialog (see - rule 11): keep effort state inside the section component, never as - dialog-level props. The read-only display is the `thinkingEffort` + `AgentInstanceEditDialog` beside the Model block. It is **Save-gated, not + direct-write**: the control is fully controlled by the parent dialog + (`value`/`onChange`) and owns no mutation. The dialog persists the selection + by embedding `effortLevel` in the locked `update_managed_agent` IPC call, so + the effort write is atomic with any access-policy change and can never race + or survive a Cancel or failed Save. There is no standalone + `persistAgentEffortLevel` setter. Its gating and option compute live in the + pure helper `ui/effortPicker.ts` (`effortPickerState`): the picker renders + only when `agent.backend.type === "local"` **AND** a `thought_level` + `effortConfigId` has been discovered from the running session (absent + pre-first-session and for runtimes/models without effort support). Local-only + is load-bearing, not cosmetic — the Rust command rejects non-local backends + because remote effort is set at deploy time via `policy_env`. Because the + control reads its inputs from the config surface the dialog already fetches + (`useAgentConfigSurface`), it integrates into the dialog's existing field + group without additional IPC. The read-only display is the `thinkingEffort` normalized field rendered by `AgentConfigPanel` via `NormalizedRow`, which already shows both facts — `field.value` (canonical, the effort the next spawn will launch with) and, when a running ACP session differs, diff --git a/desktop/src/features/agents/lib/agentConfigCore.test.mjs b/desktop/src/features/agents/lib/agentConfigCore.test.mjs index 92159ff275..520b51b99e 100644 --- a/desktop/src/features/agents/lib/agentConfigCore.test.mjs +++ b/desktop/src/features/agents/lib/agentConfigCore.test.mjs @@ -79,20 +79,93 @@ test("Goose exposes provider, model, and its real effort application key", () => scope: "global", }); - assert.equal( - field(model, "effort").optionSource, - "legacyProviderModelCatalog", - ); + assert.equal(field(model, "effort").optionSource, "harnessNative"); assert.deepEqual(field(model, "effort").currentPersistence, { kind: "envVar", - key: "BUZZ_AGENT_THINKING_EFFORT", + key: "GOOSE_THINKING_EFFORT", }); assert.deepEqual(field(model, "effort").targetApplication, { kind: "envVar", key: "GOOSE_THINKING_EFFORT", }); + // Goose reads/writes its native key at global scope — the launch projection's + // global tier is native-only, so the legacy BUZZ_AGENT_THINKING_EFFORT in the + // config is not surfaced as the effort value (it would be silently ignored). + assert.equal(field(model, "effort").value, null); }); +// Carl (review 5036131024): global/onboarding effort persistence must use the +// runtime's native key so a selection reaches the spawn. The launch projection's +// global tier reads native-only (legacy alias is record/persona-scope), so +// persisting the legacy key for Goose round-trips in the UI but is ignored at +// spawn. Both scopes derive the same persistence/application key. +for (const scope of ["global", "onboarding"]) { + test(`effort persists to the runtime native key at ${scope} scope`, () => { + const goose = deriveAgentConfigFieldModel({ + config: { ...config, env_vars: { GOOSE_THINKING_EFFORT: "high" } }, + runtime: runtime("goose", { thinkingEnvVar: "GOOSE_THINKING_EFFORT" }), + scope, + }); + const gooseEffort = field(goose, "effort"); + assert.deepEqual(gooseEffort.currentPersistence, { + kind: "envVar", + key: "GOOSE_THINKING_EFFORT", + }); + assert.deepEqual(gooseEffort.targetApplication, { + kind: "envVar", + key: "GOOSE_THINKING_EFFORT", + }); + assert.equal(gooseEffort.value, "high"); + assert.deepEqual(structuredEnvKeys([gooseEffort]), [ + "GOOSE_THINKING_EFFORT", + ]); + + const buzz = deriveAgentConfigFieldModel({ + config, + runtime: runtime("buzz-agent", { + thinkingEnvVar: "BUZZ_AGENT_THINKING_EFFORT", + }), + scope, + }); + const buzzEffort = field(buzz, "effort"); + assert.deepEqual(buzzEffort.currentPersistence, { + kind: "envVar", + key: "BUZZ_AGENT_THINKING_EFFORT", + }); + assert.equal(buzzEffort.value, "high"); + }); +} + +// Per-agent scopes (definition/instance) intentionally keep effort on the +// generic legacy BUZZ_AGENT_THINKING_EFFORT row until PR 2.7 migrates Goose — +// currentPersistence/value stay legacy while targetApplication is native +// (agents/AGENTS.md rule 2). The scope gate must not broaden to these scopes. +for (const scope of ["definition", "instance"]) { + test(`Goose effort stays on the legacy persistence key at ${scope} scope`, () => { + const model = deriveAgentConfigFieldModel({ + config: { + ...config, + env_vars: { + BUZZ_AGENT_THINKING_EFFORT: "high", + GOOSE_THINKING_EFFORT: "low", + }, + }, + runtime: runtime("goose", { thinkingEnvVar: "GOOSE_THINKING_EFFORT" }), + scope, + }); + const effort = field(model, "effort"); + assert.deepEqual(effort.currentPersistence, { + kind: "envVar", + key: "BUZZ_AGENT_THINKING_EFFORT", + }); + assert.deepEqual(effort.targetApplication, { + kind: "envVar", + key: "GOOSE_THINKING_EFFORT", + }); + assert.equal(effort.value, "high"); + }); +} + test("Claude models effort as a deferred native ACP option", () => { const model = deriveAgentConfigFieldModel({ config, @@ -561,3 +634,90 @@ test("NUMERIC_KIND_MIN_contextLimit_is_1", () => { test("NUMERIC_KIND_MIN_maxRounds_is_0", () => { assert.equal(NUMERIC_KIND_MIN.maxRounds, 0); }); + +// ── P2 regression: Goose optionSource + isHarnessNativeEffort guard ─────────── +// +// Source-level reproduction of the P2 blocker: save global Goose defaults with +// GOOSE_THINKING_EFFORT=off, then open AI defaults. Previously, optionSource +// was "legacyProviderModelCatalog" → AgentConfigFields passed the persisted key +// to useEffortAutoClear with buzz-agent provider/model vocab → "off" not in +// that list → hook deleted the valid native value on mount. Fix: emit +// "harnessNative" so AgentConfigFields can detect isHarnessNativeEffort and +// make the hook a no-op. + +test("Goose_optionSource_is_harnessNative_not_legacyProviderModelCatalog", () => { + // The sole optionSource change (P2 fix): Goose must NOT be + // "legacyProviderModelCatalog" because that routes effort into the + // buzz-agent provider/model catalog, deleting valid Goose values on mount. + const model = deriveAgentConfigFieldModel({ + config, + runtime: runtime("goose", { thinkingEnvVar: "GOOSE_THINKING_EFFORT" }), + scope: "global", + }); + const effortField = field(model, "effort"); + assert.equal( + effortField.optionSource, + "harnessNative", + 'Goose global optionSource must be "harnessNative" — "legacyProviderModelCatalog" routes to buzz-agent vocab and deletes valid `off` on mount', + ); +}); + +test("Goose_global_off_value_is_preserved_by_harnessNative_optionSource", () => { + // A saved GOOSE_THINKING_EFFORT=off must round-trip through the field model + // without deletion. The field value reflects the config value, and + // optionSource="harnessNative" signals to AgentConfigFields that the + // auto-clear hook should be a no-op (no buzz-agent vocab gate). + const savedConfig = { + ...config, + env_vars: { GOOSE_THINKING_EFFORT: "off" }, + }; + const model = deriveAgentConfigFieldModel({ + config: savedConfig, + runtime: runtime("goose", { thinkingEnvVar: "GOOSE_THINKING_EFFORT" }), + scope: "global", + }); + const effortField = field(model, "effort"); + assert.equal( + effortField.optionSource, + "harnessNative", + "Goose effort field must use harnessNative optionSource", + ); + assert.equal( + effortField.value, + "off", + "saved GOOSE_THINKING_EFFORT=off must survive round-trip through field model (not deleted by buzz-agent vocab check)", + ); +}); + +test("Goose_onboarding_optionSource_is_harnessNative", () => { + // Same contract at onboarding scope — the persistence key is the native key + // at both global and onboarding, so both must guard against buzz-agent vocab. + const model = deriveAgentConfigFieldModel({ + config, + runtime: runtime("goose", { thinkingEnvVar: "GOOSE_THINKING_EFFORT" }), + scope: "onboarding", + }); + assert.equal( + field(model, "effort").optionSource, + "harnessNative", + "Goose onboarding optionSource must also be harnessNative", + ); +}); + +test("buzz_agent_optionSource_unchanged_still_buzzAgentCatalog", () => { + // Ensure the fix did not accidentally change buzz-agent's optionSource. + // buzz-agent's effort MUST go through the provider/model catalog for the + // per-provider effort validation to work (e.g. "none" vs "off"). + const model = deriveAgentConfigFieldModel({ + config, + runtime: runtime("buzz-agent", { + thinkingEnvVar: "BUZZ_AGENT_THINKING_EFFORT", + }), + scope: "global", + }); + assert.equal( + field(model, "effort").optionSource, + "buzzAgentCatalog", + "buzz-agent optionSource must remain buzzAgentCatalog", + ); +}); diff --git a/desktop/src/features/agents/lib/agentConfigCore.ts b/desktop/src/features/agents/lib/agentConfigCore.ts index 5a8b8cb1c3..de31e724cc 100644 --- a/desktop/src/features/agents/lib/agentConfigCore.ts +++ b/desktop/src/features/agents/lib/agentConfigCore.ts @@ -204,19 +204,31 @@ export function deriveAgentConfigFieldModel({ }); if (runtime?.thinkingEnvVar) { + // targetApplication is always the runtime's native key — how the harness + // should receive effort. currentPersistence (where the value lives today) + // is scope-split until PR 2.7 migrates per-agent Goose/Claude: + // - global/onboarding: native key, matching the launch projection's global + // tier (native-only; the legacy alias is record/persona scope), so a + // selection actually reaches the spawn rather than persisting a key the + // projection ignores. For buzz-agent this IS BUZZ_AGENT_THINKING_EFFORT. + // - definition/instance: still the generic legacy BUZZ_AGENT_THINKING_EFFORT + // row, unchanged pending the per-agent migration. + const nativeKey = runtime.thinkingEnvVar; + const persistenceKey = + scope === "global" || scope === "onboarding" + ? nativeKey + : BUZZ_AGENT_THINKING_EFFORT; fields.push({ kind: "effort", optionSource: - runtime.id === "buzz-agent" - ? "buzzAgentCatalog" - : "legacyProviderModelCatalog", + runtime.id === "buzz-agent" ? "buzzAgentCatalog" : "harnessNative", currentPersistence: { kind: "envVar", - key: BUZZ_AGENT_THINKING_EFFORT, + key: persistenceKey, }, - targetApplication: { kind: "envVar", key: runtime.thinkingEnvVar }, + targetApplication: { kind: "envVar", key: nativeKey }, render: "control", - value: valueFromEnv(config, BUZZ_AGENT_THINKING_EFFORT), + value: valueFromEnv(config, persistenceKey), }); } else if (runtime?.id === "claude") { fields.push({ diff --git a/desktop/src/features/agents/ui/AgentConfigFields.tsx b/desktop/src/features/agents/ui/AgentConfigFields.tsx index 52a58c9134..5e0a4ab961 100644 --- a/desktop/src/features/agents/ui/AgentConfigFields.tsx +++ b/desktop/src/features/agents/ui/AgentConfigFields.tsx @@ -86,14 +86,10 @@ type AgentConfigDisclosure = | "onboarding-essential" | "progressive-defaults"; -// Canonical behaviors (PR 2 flag cleanup). These were per-surface props; -// onboarding's values won every call and are now the only behavior: -// - auto-select a valid model when the provider changes -// - keep the model select usable during discovery -// - preserve credential env vars across provider switches (the abandoned -// provider's key stays in env_vars — visible/deletable under Advanced) -// - require a provider before model/effort are editable (no saveable -// invalid state — design principle #4) +// Canonical behaviors (formerly per-surface props; onboarding's values won +// every call and are now the only behavior). Design principle #4: require a +// provider before model/effort are editable; preserve credential env vars +// across provider switches; auto-select model on provider change. const autoSelectModelOnProviderChange = true; const disableModelSelectDuringDiscovery = false; const preserveCredentialEnvVarsOnProviderChange = true; @@ -255,6 +251,11 @@ export function AgentConfigFields({ effortField?.currentPersistence.kind === "envVar" ? effortField.currentPersistence.key : null; + // True when the runtime owns its own effort vocabulary (e.g. Goose) and + // should bypass the buzz-agent provider/model catalog: harnessNative + envVar. + const isHarnessNativeEffort = + effortField?.optionSource === "harnessNative" && + effortField?.currentPersistence.kind === "envVar"; const numericDescriptors = fieldModel.fields.filter( (d): d is NumericDescriptor => @@ -287,8 +288,7 @@ export function AgentConfigFields({ const modelField = fieldModel.fields.find( (field) => field.kind === "model" && field.render === "control", ); - // CLI-login harnesses apply this setting through ACP rather than an env var - // and provide their own default when no model override is persisted. + // CLI-login harnesses use ACP for this setting; they provide their own default. const modelIsOptional = modelField?.targetApplication.kind === "acpNative"; const modelIsValid = modelIsOptional || @@ -384,20 +384,15 @@ export function AgentConfigFields({ showCustomModelOption, }); - // Mount-time healing policy: onboarding page 4 edits the root config during - // first-run (no higher layers to inherit from), so acting on open is safe - // and intentional there — it heals stale state and picks a valid model. - // Evergreen surfaces (Settings, dialogs) edit saved data that may pair with - // higher layers (see PR #2148 review thread), so they only act after the - // user explicitly edits the provider in this session. + // Mount-time healing policy: onboarding (first-run, no higher layers) heals + // on open. Evergreen surfaces (Settings, dialogs) only heal after an explicit + // provider edit — acting on open would break multi-layer configs (PR #2148). const healOnMount = fieldModel.dependentValuePolicy.onCatalogMismatch === "onboardingCleanup"; const userEditedProviderRef = React.useRef(false); - // Advanced visibility is user-controlled. Provider changes can add required - // rows, but must not open this section without an explicit toggle click. + // Advanced visibility is user-controlled; must not auto-open on provider change. const [advancedOpen, setAdvancedOpen] = React.useState(false); - // Read inside effects via ref so biome's exhaustive-deps stays honest: - // refs are stable, and healOnMount is captured at declaration. + // Stable ref for effects; healOnMount is captured at declaration time. const mayMutateDependentFieldsRef = React.useRef(false); mayMutateDependentFieldsRef.current = healOnMount || userEditedProviderRef.current; @@ -439,14 +434,10 @@ export function AgentConfigFields({ ? (config.env_vars[effortPersistenceKey] ?? "") : ""; - // When the selected harness changes outside this component (Back → setup - // page → choose a different harness → Next), the saved model can belong to - // the old harness. In onboarding, heal that stale value as soon as the new - // harness catalog proves it is unsupported; otherwise a Codex id like - // `gpt-5.5[low]` appears as a Claude Code custom model. - // Also clear when the Model control is omitted after a confirmed successful - // empty catalog — never while discovery failed/unavailable (transient - // failures must not erase saved model/effort). + // Heal a stale model when the harness changes (e.g. Back → pick different + // harness → Next in onboarding). Clear once the new catalog proves the saved + // model unsupported; also clear when Model is omitted after a confirmed empty + // catalog. Never clear on failure/unavailable — transient errors must not erase. React.useEffect(() => { if (!healOnMount) return; const currentModel = (config.model ?? "").trim(); @@ -463,7 +454,9 @@ export function AgentConfigFields({ if (!catalogMiss && !omittedAfterSuccessfulEmpty) return; const nextEnvVars = { ...config.env_vars }; - if (effortPersistenceKey) delete nextEnvVars[effortPersistenceKey]; + // Harness-native effort is model-independent; never clear it on a catalog miss. + if (effortPersistenceKey && !isHarnessNativeEffort) + delete nextEnvVars[effortPersistenceKey]; onCustomModelEditingChange(false); onConfigChange({ ...config, env_vars: nextEnvVars, model: null }); }, [ @@ -477,28 +470,29 @@ export function AgentConfigFields({ onCustomModelEditingChange, healOnMount, effortPersistenceKey, + isHarnessNativeEffort, ]); // Orphan-model clearing follows the mount-time healing policy above: the - // backend resolves provider and model independently across layers - // (agent → definition → global), so a saved global model WITHOUT a global - // provider can be a deliberate, working pattern (provider supplied by a - // higher layer). Clearing it on page-open in evergreen surfaces silently - // breaks that agent on its next restart — see PR #2148 review thread. - // Onboarding heals on open by design (discriminating spec: "gates stale - // saved model and effort until provider selection"). + // backend resolves provider+model independently across layers, so a global + // model without a global provider can be deliberate (PR #2148). Evergreen + // surfaces only clear on explicit edit; onboarding heals on open. React.useEffect(() => { if (!mayMutateDependentFieldsRef.current) return; if (!dependentFieldsDisabled) return; + // When model is absent, harness-native effort is model-independent so no + // orphan to fix; non-native effort with no value is already clean. if ( (config.model ?? "").trim().length === 0 && - currentEffortForAutoClear.length === 0 - ) { + (isHarnessNativeEffort || currentEffortForAutoClear.length === 0) + ) return; - } const nextEnvVars = { ...config.env_vars }; - if (effortPersistenceKey) delete nextEnvVars[effortPersistenceKey]; + // Preserve harness-native effort — it is model-independent and must survive + // the provider→Custom transition. + if (effortPersistenceKey && !isHarnessNativeEffort) + delete nextEnvVars[effortPersistenceKey]; onCustomModelEditingChange(false); onConfigChange({ ...config, env_vars: nextEnvVars, model: null }); }, [ @@ -508,13 +502,16 @@ export function AgentConfigFields({ onConfigChange, onCustomModelEditingChange, effortPersistenceKey, + isHarnessNativeEffort, ]); + // `useEffortAutoClear` must not delete a valid harness-native value (e.g. + // "off" for Goose). Suppress it by passing "" as the current effort. const { validValues: effortValidForAutoClear } = getProviderEffortConfig( config.provider ?? "", config.model ?? "", ); useEffortAutoClear({ - currentEffort: currentEffortForAutoClear, + currentEffort: isHarnessNativeEffort ? "" : currentEffortForAutoClear, effortValid: effortValidForAutoClear, onClear: () => { const nextEnvVars = { ...config.env_vars }; @@ -635,6 +632,11 @@ export function AgentConfigFields({ : implicitEffortProvider; const { validValues: effortValid, defaultValue: effortDefault } = getProviderEffortConfig(effortProvider, config.model ?? ""); + // Harness-native runtimes own their effort vocabulary via the catalog entry. + const effortValidForRenderer = isHarnessNativeEffort + ? (selectedRuntime?.effortCanonicalValues ?? []) + : effortValid; + const effortDefaultForRenderer = isHarnessNativeEffort ? null : effortDefault; const currentEffort = effortPersistenceKey ? (config.env_vars[effortPersistenceKey] ?? "") : ""; @@ -853,21 +855,21 @@ export function AgentConfigFields({ currentEffort={dependentFieldsDisabled ? "" : currentEffort} disabled={dependentFieldsDisabled} emptyOptionLabel={ - // Semantic, not copy: onboarding-essential hides inheritance - // concepts (first-run users pick, they don't inherit), so the - // zero option is a plain placeholder. Full disclosure leaves - // this unset so EffortSelectField computes the inherit/default - // label ("Default (medium)", "Inherit (high)", …). + // Onboarding-essential hides inherit/default labels; show a plain + // placeholder. Full disclosure lets EffortSelectField compute + // its own label ("Default (medium)", "Inherit (high)", …). disclosure === "onboarding-essential" ? "Select effort level" : undefined } - effortDefault={effortDefault} - effortValid={effortValid} + effortDefault={effortDefaultForRenderer} + effortValid={effortValidForRenderer} fieldClassName={unstyled ? fieldClassName : undefined} htmlFor="global-agent-thinking-effort" inheritFallbackLabel={ - effortDefault !== null ? `Default (${effortDefault})` : undefined + effortDefaultForRenderer !== null + ? `Default (${effortDefaultForRenderer})` + : undefined } inheritedEffort={bakedEffort ?? undefined} label="Effort" diff --git a/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx b/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx index 62d85d385d..205cf13a44 100644 --- a/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx +++ b/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx @@ -1,9 +1,11 @@ import * as React from "react"; +import { useQueryClient } from "@tanstack/react-query"; import { ChevronDown } from "lucide-react"; import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import { toast } from "sonner"; import { + agentConfigSurfaceQueryKey, useAcpRuntimesQuery, useAgentConfigSurface, useBakedBuildEnvKeysQuery, @@ -40,7 +42,6 @@ import { NO_RUNTIME_DROPDOWN_VALUE, PERSONA_FIELD_CONTROL_CLASS, PERSONA_FIELD_SHELL_CLASS, - PERSONA_LABEL_OPTIONAL_CLASS, runtimeSupportsLlmProviderSelection, shouldClearKnownModelForSelectionScope, sortPersonaRuntimes, @@ -55,6 +56,7 @@ import { envVarsEqual, isEditAgentProviderSaveValid, resolveAgentCommandUpdate, + resolveEffortSubmission, resolveInheritedRuntimeSubmission, resolveRuntimeProviderCapability, } from "./personaRuntimeModel"; @@ -74,15 +76,12 @@ import { MODEL_DISCOVERY_LOADING_VALUE, usePersonaModelDiscovery, } from "./usePersonaModelDiscovery"; -import { PersonaProviderApiKeyField } from "./PersonaProviderApiKeyField"; +import { EditAgentProviderModelFields } from "./EditAgentProviderModelFields"; import { getBakedModelInheritLabel, getBakedProviderInheritLabel, } from "./bakedEnvHelpers"; -import { - getProviderApiKeyEnvVar, - getProviderApiKeyLabel, -} from "./agentConfigOptions"; +import { getProviderApiKeyEnvVar } from "./agentConfigOptions"; import { useAgentDialogDefaults } from "./useAgentDialogDefaults"; import { AgentAiDefaultsNotice } from "./AgentAiDefaults"; import { AgentDefaultsDialog } from "./AgentDefaultsDialog"; @@ -116,6 +115,13 @@ export function AgentInstanceEditDialog({ }) { const updateMutation = useUpdateManagedAgentMutation(); const startMutation = useStartManagedAgentMutation(); + const queryClient = useQueryClient(); + // Spans the COMPLETE Save sequence (locked update + standalone setters). + // Every gate must key off this, not updateMutation.isPending alone. + const [isSaving, setIsSaving] = React.useState(false); + // Surfaces a standalone-setter failure (auto-restart or effort) that React + // Query does not track — keeps the dialog open so the user can retry Save. + const [setterError, setSetterError] = React.useState(null); const runtimesQuery = useAcpRuntimesQuery({ enabled: open }); const configSurfaceQuery = useAgentConfigSurface(open ? agent.pubkey : null); const runtimes = runtimesQuery.data ?? []; @@ -146,6 +152,13 @@ export function AgentInstanceEditDialog({ const [envVars, setEnvVars] = React.useState(agent.envVars); const [autoRestartOnConfigChange, setAutoRestartOnConfigChange] = React.useState(agent.autoRestartOnConfigChange); + // Effort picker is Save-gated: hold the pending selection in dialog state and + // embed it in the locked update payload on Save alone (see + // resolveEffortSubmission / handleSubmit — PR #4625), never on selection. + // `effortTouched` distinguishes "user picked a value" from "showing the + // config-surface effective value", so an untouched Save writes nothing. + const [effortLevel, setEffortLevel] = React.useState(null); + const effortTouched = React.useRef(false); const personasQuery = usePersonasQuery(); const linkedPersona = React.useMemo( () => @@ -195,6 +208,9 @@ export function AgentInstanceEditDialog({ setIsCustomProviderEditing(false); setEnvVars(agent.envVars); setAutoRestartOnConfigChange(agent.autoRestartOnConfigChange); + setEffortLevel(null); + effortTouched.current = false; + setSetterError(null); setRespondTo(agent.respondTo); setRespondToAllowlist(agent.respondToAllowlist); setAvatarUrl(agent.avatarUrl ?? ""); @@ -507,10 +523,10 @@ export function AgentInstanceEditDialog({ // Mark that the user has made an explicit runtime choice. The catalog-arrival // effect will no longer overwrite selectedRuntimeId after this point. runtimeTouched.current = true; - const resolvedRuntimeId = nextRuntimeId || "custom"; setSelectedRuntimeId(resolvedRuntimeId); - + effortTouched.current = false; + setEffortLevel(null); const isCustomCommand = resolvedRuntimeId === "custom"; // Only pin the harness when the selection can actually supply a command: @@ -588,6 +604,10 @@ export function AgentInstanceEditDialog({ } function handleOpenChange(next: boolean) { + // Reject user-originated dismissals (Escape, overlay, close-X, Cancel) while + // a Save is in flight — the in-flight setters must not commit to a closed dialog. + // The success path calls onOpenChange(false) directly, bypassing this guard. + if (!next && isSaving) return; onOpenChange(next); } @@ -613,10 +633,12 @@ export function AgentInstanceEditDialog({ requiredEnvKeyMissing, }) && providerValid && - !updateMutation.isPending && + !isSaving && !isAvatarUploadPending; async function handleSubmit() { + setIsSaving(true); + setSetterError(null); try { const parsedParallelism = Number.parseInt(parallelism, 10); const parsedArgs = agentArgs @@ -725,17 +747,53 @@ export function AgentInstanceEditDialog({ : undefined, }; - const result = await updateMutation.mutateAsync(input); - if (autoRestartOnConfigChange !== agent.autoRestartOnConfigChange) { - // Standalone setter (mirrors start-on-app-launch) — not part of - // UpdateManagedAgentInput, so the frozen update shape stays frozen. - await setManagedAgentAutoRestart( - agent.pubkey, - autoRestartOnConfigChange, - ); + // Resolve effort before the update so access-change restarts can + // snapshot and launch the NEW effort value atomically. + const effortSubmission = resolveEffortSubmission({ + effortLevel, + originalEffortLevel: + configSurfaceQuery.data?.normalized.thinkingEffort?.value ?? null, + inheritTransition: agentCommandUpdate === "", + }); + // Include effort in the locked update when touched (tri-state: absent = + // don't touch; null = clear; string = set). Only when effortSubmission.persist. + if (effortTouched.current && effortSubmission.persist) { + input.effortLevel = effortSubmission.level; } + + const result = await updateMutation.mutateAsync(input); + + // Standalone setters — sequenced after the locked update resolves so the + // dialog remains fully gated (isSaving) for the COMPLETE Save transaction. + // A failure here surfaces as setterError (retryable) and aborts before + // close, keeping the dialog open so the user can retry Save. + try { + if (autoRestartOnConfigChange !== agent.autoRestartOnConfigChange) { + // Mirrors start-on-app-launch; not part of UpdateManagedAgentInput so + // the frozen update shape stays frozen. + await setManagedAgentAutoRestart( + agent.pubkey, + autoRestartOnConfigChange, + ); + } + // Effort disk write happened inside the locked update. Only need to + // invalidate the cache here (when effortTouched && effortSubmission.persist). + // If effort was not included (!effortSubmission.persist), nothing to do. + if (effortTouched.current && effortSubmission.persist) { + // Disk write already done; invalidate so the panel tier reflects it. + await queryClient.invalidateQueries({ + queryKey: agentConfigSurfaceQueryKey(agent.pubkey), + }); + } + } catch (e) { + setSetterError(e instanceof Error ? e : new Error("Failed to save")); + return; + } + showAgentProfileSyncWarning(result.agent.name, result.profileSyncError); - handleOpenChange(false); + // Close via onOpenChange directly — handleOpenChange guards against + // mid-save dismissal and must not block the intentional post-success close. + onOpenChange(false); onUpdated?.(result.agent); // The auto-restart policy deliberately never fires for a stopped or // failing agent (a broken agent must not auto-loop), so an edit meant @@ -761,7 +819,9 @@ export function AgentInstanceEditDialog({ }); } } catch { - // React Query stores the error; keep dialog open and render it inline. + // React Query stores the update error; keep dialog open and render it inline. + } finally { + setIsSaving(false); } } @@ -844,6 +904,11 @@ export function AgentInstanceEditDialog({ const advancedFieldsTransition = shouldReduceMotion ? { duration: 0 } : ADVANCED_FIELDS_MOTION_TRANSITION; + // Displayed inline when either the locked update or a standalone setter fails. + // setterError takes precedence — the update already committed when it fires. + const displayError = + setterError ?? + (updateMutation.error instanceof Error ? updateMutation.error : null); return ( @@ -857,7 +922,7 @@ export function AgentInstanceEditDialog({ footer={
} @@ -890,6 +955,7 @@ export function AgentInstanceEditDialog({ {onEditLinkedPersona ? (