diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 90ae2f16..716ee02b 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -167,9 +167,162 @@ jobs:
- name: Check docs build cleanly
run: mix docs --warnings-as-errors
+ optional_dep_oban_absent:
+ name: Optional dep off - oban
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ - uses: erlef/setup-beam@fc68ffb90438ef2936bbb3251622353b3dcb2f93 # v1.24.0
+ with:
+ version-file: .tool-versions
+ version-type: strict
+ - name: Cache library deps
+ uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
+ with:
+ path: |
+ deps
+ _build
+ key: ${{ runner.os }}-library-oban-off-${{ hashFiles('mix.lock') }}
+ - name: Install Hex + Rebar
+ run: |
+ mix local.hex --force
+ mix local.rebar --force
+ - name: Remove oban from this lane
+ run: |
+ perl -0pi -e 's/\n\s*\{:oban, "~> 2\.17", optional: true\},//g' mix.exs
+ - name: Fetch reduced dependency set
+ run: mix deps.get
+ - name: Verify oban-off failure surfaces
+ env:
+ MIX_ENV: test
+ run: |
+ mix test test/sigra/delivery_test.exs:103 test/sigra/workers/optional_deps_test.exs:32
+ if mix sigra.doctor --delivery-mode=async; then
+ echo "expected mix sigra.doctor --delivery-mode=async to fail without oban"
+ exit 1
+ fi
+ mix run -e '
+ try do
+ Sigra.Workers.AccountDeletion.new(%{"user_id" => 1}, [])
+ IO.puts("expected MissingDependencyError for oban-off lane")
+ System.halt(1)
+ rescue
+ error in Sigra.OptionalDeps.MissingDependencyError ->
+ if error.feature == :lifecycle_jobs do
+ IO.puts("account deletion worker raised tagged lifecycle dependency error")
+ else
+ IO.puts("unexpected feature: #{inspect(error.feature)}")
+ System.halt(1)
+ end
+ end
+ '
+
+ optional_dep_bcrypt_absent:
+ name: Optional dep off - bcrypt
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ - uses: erlef/setup-beam@fc68ffb90438ef2936bbb3251622353b3dcb2f93 # v1.24.0
+ with:
+ version-file: .tool-versions
+ version-type: strict
+ - name: Cache library deps
+ uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
+ with:
+ path: |
+ deps
+ _build
+ key: ${{ runner.os }}-library-bcrypt-off-${{ hashFiles('mix.lock') }}
+ - name: Install Hex + Rebar
+ run: |
+ mix local.hex --force
+ mix local.rebar --force
+ - name: Remove bcrypt_elixir from this lane
+ run: |
+ perl -0pi -e 's/\n\s*\{:bcrypt_elixir, "~> 3\.3", optional: true\},//g' mix.exs
+ - name: Fetch reduced dependency set
+ run: mix deps.get
+ - name: Verify bcrypt-off failure surfaces
+ env:
+ MIX_ENV: test
+ run: |
+ mix test test/sigra/crypto_test.exs:126
+ mix run -e '
+ hash = "$2b$12$WApznUPhDubN0oeveSXHp.Raz0RCbZCjJjVEqMlKsXXYb.1VZFBi2"
+
+ try do
+ Sigra.Crypto.verify_with_upgrade("password123", hash)
+ IO.puts("expected MissingDependencyError for bcrypt-off lane")
+ System.halt(1)
+ rescue
+ error in Sigra.OptionalDeps.MissingDependencyError ->
+ if error.feature == :bcrypt_migration do
+ IO.puts("bcrypt verification raised tagged dependency error")
+ else
+ IO.puts("unexpected feature: #{inspect(error.feature)}")
+ System.halt(1)
+ end
+ end
+ '
+
+ optional_dep_eqrcode_absent:
+ name: Optional dep off - eqrcode
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ - uses: erlef/setup-beam@fc68ffb90438ef2936bbb3251622353b3dcb2f93 # v1.24.0
+ with:
+ version-file: .tool-versions
+ version-type: strict
+ - name: Cache library deps
+ uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
+ with:
+ path: |
+ deps
+ _build
+ key: ${{ runner.os }}-library-eqrcode-off-${{ hashFiles('mix.lock') }}
+ - name: Install Hex + Rebar
+ run: |
+ mix local.hex --force
+ mix local.rebar --force
+ - name: Remove eqrcode from this lane
+ run: |
+ perl -0pi -e 's/\n\s*\{:eqrcode, "~> 0\.2\.1", optional: true\},//g' mix.exs
+ - name: Fetch reduced dependency set
+ run: mix deps.get
+ - name: Verify eqrcode-off failure surfaces
+ env:
+ MIX_ENV: test
+ run: |
+ mix test test/sigra/mfa_test.exs:18
+ mix run -e '
+ config = Sigra.Config.new!(
+ repo: Sigra.MockRepo,
+ user_schema: Sigra.TestUser,
+ secret_key_base: String.duplicate("a", 64),
+ mfa: [enabled: true, totp_issuer: nil]
+ )
+
+ try do
+ Sigra.MFA.enroll(config)
+ IO.puts("expected MissingDependencyError for eqrcode-off lane")
+ System.halt(1)
+ rescue
+ error in Sigra.OptionalDeps.MissingDependencyError ->
+ if error.feature == :totp_qr do
+ IO.puts("mfa enrollment raised tagged dependency error")
+ else
+ IO.puts("unexpected feature: #{inspect(error.feature)}")
+ System.halt(1)
+ end
+ end
+ '
+
example_unit_smoke:
name: Example unit smoke (ExUnit + ConnTest)
runs-on: ubuntu-latest
+ env:
+ CLOAK_KEY: MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=
services:
postgres:
image: postgres:15
@@ -220,6 +373,8 @@ jobs:
# checks (see scripts/ci/install-smoke.sh and docs/uat-ci-coverage.md).
name: Install smoke (fresh phx.new + sigra.install)
runs-on: ubuntu-latest
+ permissions:
+ contents: write
services:
postgres:
image: postgres:15
@@ -253,7 +408,7 @@ jobs:
mix local.hex --force
mix local.rebar --force
- name: Install phx_new archive
- run: mix archive.install --force hex phx_new
+ run: mix archive.install --force hex phx_new 1.8.5
- name: Fetch Sigra library deps
run: mix deps.get
- name: Run install smoke harness
@@ -264,7 +419,73 @@ jobs:
GITHUB_WORKSPACE: ${{ github.workspace }}
# Fresh tmp_app includes Cloak.Vault; compile/boot needs a dummy key (same as admin-acceptance-smoke.sh).
CLOAK_KEY: MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=
- run: scripts/ci/install-smoke.sh
+ run: |
+ mkdir -p .planning/uat-evidence/v1.20/oauth-gen
+ scripts/ci/install-smoke.sh 2>&1 | tee .planning/uat-evidence/v1.20/oauth-gen/transcript.log
+ - name: Generate install-smoke evidence reports
+ env:
+ MIX_ENV: test
+ SIGRA_CI_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
+ SIGRA_CI_WORKFLOW: .github/workflows/ci.yml / install_smoke
+ SIGRA_GIT_TAG: ${{ github.ref_name }}
+ run: |
+ MIX_ENV=test mix sigra.uat.report --phase=oauth-gen
+ MIX_ENV=test mix sigra.uat.report --phase=getting-started
+ - name: Upload oauth-gen bundle (main, 14d retention)
+ if: always() && github.ref == 'refs/heads/main'
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+ with:
+ name: oauth-gen-bundle
+ path: .planning/uat-evidence/v1.20/oauth-gen/
+ retention-days: 14
+ - name: Upload oauth-gen bundle (PR/push, 7d retention)
+ if: always() && github.ref != 'refs/heads/main' && !startsWith(github.ref, 'refs/tags/')
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+ with:
+ name: oauth-gen-bundle
+ path: .planning/uat-evidence/v1.20/oauth-gen/
+ retention-days: 7
+ - name: Create oauth-gen release asset archive (v* tag only)
+ if: startsWith(github.ref, 'refs/tags/v')
+ run: |
+ cd /tmp && tar -czf "sigra-oauth-gen-${{ github.ref_name }}.tar.gz" \
+ -C "${{ github.workspace }}" .planning/uat-evidence/v1.20/oauth-gen/
+ - name: Promote oauth-gen bundle to ${{ github.ref_name }} release asset
+ if: startsWith(github.ref, 'refs/tags/v')
+ env:
+ GH_TOKEN: ${{ github.token }}
+ run: |
+ gh release upload "${{ github.ref_name }}" "/tmp/sigra-oauth-gen-${{ github.ref_name }}.tar.gz" \
+ --clobber \
+ --repo "${{ github.repository }}"
+ - name: Upload oauth-gen bundle (tag, 90d retention)
+ if: startsWith(github.ref, 'refs/tags/')
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+ with:
+ name: oauth-gen-bundle
+ path: .planning/uat-evidence/v1.20/oauth-gen/
+ retention-days: 90
+ - name: Upload getting-started bundle (main, 14d retention)
+ if: always() && github.ref == 'refs/heads/main'
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+ with:
+ name: getting-started-generated-host-bundle
+ path: .planning/uat-evidence/v1.20/getting-started-clean-machine/
+ retention-days: 14
+ - name: Upload getting-started bundle (PR/push, 7d retention)
+ if: always() && github.ref != 'refs/heads/main' && !startsWith(github.ref, 'refs/tags/')
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+ with:
+ name: getting-started-generated-host-bundle
+ path: .planning/uat-evidence/v1.20/getting-started-clean-machine/
+ retention-days: 7
+ - name: Upload getting-started bundle (tag, 90d retention)
+ if: startsWith(github.ref, 'refs/tags/')
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+ with:
+ name: getting-started-generated-host-bundle
+ path: .planning/uat-evidence/v1.20/getting-started-clean-machine/
+ retention-days: 90
passkeys_manual_fallback_smoke:
name: Passkeys manual fallback smoke
@@ -487,6 +708,8 @@ jobs:
example_http_smoke:
name: Example HTTP smoke (boot + curl critical routes)
runs-on: ubuntu-latest
+ env:
+ CLOAK_KEY: MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=
services:
postgres:
image: postgres:15
@@ -551,6 +774,8 @@ jobs:
example_playwright_smoke:
name: Example Playwright smoke (full lifecycle)
runs-on: ubuntu-latest
+ env:
+ CLOAK_KEY: MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=
services:
postgres:
image: postgres:15
@@ -788,6 +1013,306 @@ jobs:
path: test/example/priv/playwright/test-results/
retention-days: 7
+ oauth_e2e_playwright:
+ name: OAuth E2E Playwright (mock issuer)
+ runs-on: ubuntu-latest
+ permissions:
+ contents: write
+ env:
+ EXAMPLE_DB_PROBE_ENABLED: "1"
+ EXAMPLE_OAUTH_ISSUER_CTL_ENABLED: "1"
+ CLOAK_KEY: MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=
+ services:
+ postgres:
+ image: postgres:15
+ env:
+ POSTGRES_PASSWORD: postgres
+ ports: ['5432:5432']
+ options: >-
+ --health-cmd pg_isready --health-interval 10s
+ --health-timeout 5s --health-retries 5
+ steps:
+ - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ - uses: erlef/setup-beam@fc68ffb90438ef2936bbb3251622353b3dcb2f93 # v1.24.0
+ with:
+ version-file: .tool-versions
+ version-type: strict
+ - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
+ with:
+ node-version: '20'
+ cache: 'npm'
+ cache-dependency-path: 'test/example/priv/playwright/package-lock.json'
+ - name: Cache example deps
+ uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
+ with:
+ path: |
+ test/example/deps
+ test/example/_build
+ key: ${{ runner.os }}-example-dev-${{ hashFiles('test/example/mix.lock', 'test/example/config/**', 'test/example/lib/**/*.ex', 'lib/**/*.ex', 'mix.exs') }}
+ - name: Fetch example deps
+ working-directory: test/example
+ env:
+ MIX_ENV: dev
+ run: mix deps.get
+ - name: Compile example
+ working-directory: test/example
+ env:
+ MIX_ENV: dev
+ run: mix compile --warnings-as-errors
+ - name: Setup example dev DB
+ working-directory: test/example
+ env:
+ MIX_ENV: dev
+ PGUSER: postgres
+ PGPASSWORD: postgres
+ PGHOST: localhost
+ run: mix ecto.create && mix ecto.migrate
+ - name: Install Playwright deps
+ working-directory: test/example/priv/playwright
+ run: npm ci
+ - name: Install Playwright browsers
+ working-directory: test/example/priv/playwright
+ run: npx playwright install --with-deps chromium
+ - name: Boot example app in background
+ working-directory: test/example
+ env:
+ MIX_ENV: dev
+ PGUSER: postgres
+ PGPASSWORD: postgres
+ PGHOST: localhost
+ PHX_SERVER: "true"
+ run: mix phx.server > /tmp/example-oauth-playwright-server.log 2>&1 &
+ - name: Wait for app and warm OAuth routes
+ run: |
+ for i in $(seq 1 30); do
+ if curl -sf http://localhost:4000/ > /dev/null; then
+ echo "App responding after ${i}s"
+ break
+ fi
+ sleep 1
+ done
+ for path in /users/log_in /users/settings /test/db_probe /test/oauth_issuer/reset; do
+ curl -s -o /dev/null -w "%{http_code} ${path}\n" "http://localhost:4000${path}" || true
+ done
+ - name: Run OAuth Playwright specs
+ working-directory: test/example/priv/playwright
+ env:
+ CI: "true"
+ SIGRA_EXAMPLE_URL: "http://localhost:4000"
+ run: |
+ npx playwright test \
+ tests/oauth-register.spec.ts \
+ tests/oauth-link.spec.ts \
+ tests/oauth-email-match.spec.ts \
+ --project=chromium \
+ --reporter=line
+ - name: Generate OAuth evidence reports
+ # `mix sigra.uat.report` is a sigra-provided mix task. Run from
+ # test/example/ (where sigra is a path dep with deps already fetched
+ # by this job's earlier steps in MIX_ENV=dev) rather than the repo
+ # root (whose mix.exs has not had deps.get run in this job — that's
+ # why the previous repo-root invocation failed with "the dependency
+ # is not available, run mix deps.get"). Mirrors the MFA evidence
+ # report fix in 2974be6.
+ working-directory: test/example
+ env:
+ MIX_ENV: dev
+ SIGRA_CI_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
+ SIGRA_CI_WORKFLOW: .github/workflows/ci.yml / oauth_e2e_playwright
+ SIGRA_GIT_TAG: ${{ github.ref_name }}
+ run: |
+ mix sigra.uat.report --phase=oauth-google
+ mix sigra.uat.report --phase=oauth-link
+ mix sigra.uat.report --phase=oauth-email-match
+ - name: Dump example app log (on failure)
+ if: failure()
+ run: |
+ echo "--- /tmp/example-oauth-playwright-server.log ---"
+ cat /tmp/example-oauth-playwright-server.log || echo "(no log file)"
+ - name: Assemble oauth e2e bundle
+ if: always()
+ run: |
+ mkdir -p /tmp/oauth-e2e-playwright-bundle
+ cp -r .planning/uat-evidence/v1.20/oauth-google /tmp/oauth-e2e-playwright-bundle/oauth-google 2>/dev/null || true
+ cp -r .planning/uat-evidence/v1.20/oauth-link /tmp/oauth-e2e-playwright-bundle/oauth-link 2>/dev/null || true
+ cp -r .planning/uat-evidence/v1.20/oauth-email-match /tmp/oauth-e2e-playwright-bundle/oauth-email-match 2>/dev/null || true
+ cp -r test/example/priv/playwright/playwright-report /tmp/oauth-e2e-playwright-bundle/playwright-report 2>/dev/null || true
+ cp -r test/example/priv/playwright/__snapshots__/oauth-link.spec.ts /tmp/oauth-e2e-playwright-bundle/oauth-link-hero 2>/dev/null || true
+ echo "Bundle contents:"
+ find /tmp/oauth-e2e-playwright-bundle -type f | sort || true
+ - name: Upload oauth e2e playwright bundle (main, 14d retention)
+ if: always() && github.ref == 'refs/heads/main'
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+ with:
+ name: oauth-e2e-playwright-bundle
+ path: /tmp/oauth-e2e-playwright-bundle/
+ retention-days: 14
+ - name: Upload oauth e2e playwright bundle (PR/push, 7d retention)
+ if: always() && github.ref != 'refs/heads/main' && !startsWith(github.ref, 'refs/tags/')
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+ with:
+ name: oauth-e2e-playwright-bundle
+ path: /tmp/oauth-e2e-playwright-bundle/
+ retention-days: 7
+ - name: Upload oauth e2e playwright failure diagnostics (main, 14d retention)
+ if: failure() && github.ref == 'refs/heads/main'
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+ with:
+ name: oauth-e2e-playwright-failure-diagnostics
+ path: test/example/priv/playwright/test-results/
+ retention-days: 14
+ - name: Upload oauth e2e playwright failure diagnostics (PR/push, 7d retention)
+ if: failure() && github.ref != 'refs/heads/main' && !startsWith(github.ref, 'refs/tags/')
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+ with:
+ name: oauth-e2e-playwright-failure-diagnostics
+ path: test/example/priv/playwright/test-results/
+ retention-days: 7
+ - name: Create oauth e2e playwright release asset archive (v* tag only)
+ if: startsWith(github.ref, 'refs/tags/v')
+ run: |
+ cd /tmp && tar -czf "sigra-oauth-e2e-playwright-${{ github.ref_name }}.tar.gz" oauth-e2e-playwright-bundle/
+ echo "Release asset: sigra-oauth-e2e-playwright-${{ github.ref_name }}.tar.gz"
+ ls -lh "/tmp/sigra-oauth-e2e-playwright-${{ github.ref_name }}.tar.gz"
+ - name: Promote oauth e2e playwright bundle to ${{ github.ref_name }} release asset
+ if: startsWith(github.ref, 'refs/tags/v')
+ env:
+ GH_TOKEN: ${{ github.token }}
+ run: |
+ gh release upload "${{ github.ref_name }}" "/tmp/sigra-oauth-e2e-playwright-${{ github.ref_name }}.tar.gz" \
+ --clobber \
+ --repo "${{ github.repository }}"
+ - name: Upload oauth e2e playwright bundle (tag, 90d retention)
+ if: startsWith(github.ref, 'refs/tags/')
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+ with:
+ name: oauth-e2e-playwright-bundle
+ path: /tmp/oauth-e2e-playwright-bundle/
+ retention-days: 90
+
+ mfa_e2e_playwright:
+ name: MFA backup-code rotation E2E
+ runs-on: ubuntu-latest
+ permissions:
+ contents: write
+ env:
+ EXAMPLE_DB_PROBE_ENABLED: "1"
+ CLOAK_KEY: MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=
+ services:
+ postgres:
+ image: postgres:15
+ env:
+ POSTGRES_PASSWORD: postgres
+ ports: ['5432:5432']
+ options: >-
+ --health-cmd pg_isready --health-interval 10s
+ --health-timeout 5s --health-retries 5
+ steps:
+ - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ - uses: erlef/setup-beam@fc68ffb90438ef2936bbb3251622353b3dcb2f93 # v1.24.0
+ with:
+ version-file: .tool-versions
+ version-type: strict
+ - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
+ with:
+ node-version: '20'
+ cache: 'npm'
+ cache-dependency-path: 'test/example/priv/playwright/package-lock.json'
+ - name: Cache example deps
+ uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
+ with:
+ path: |
+ test/example/deps
+ test/example/_build
+ key: ${{ runner.os }}-example-dev-${{ hashFiles('test/example/mix.lock', 'test/example/config/**', 'test/example/lib/**/*.ex', 'lib/**/*.ex', 'mix.exs') }}
+ - name: Fetch example deps
+ working-directory: test/example
+ env:
+ MIX_ENV: dev
+ run: mix deps.get
+ - name: Compile example
+ working-directory: test/example
+ env:
+ MIX_ENV: dev
+ run: mix compile --warnings-as-errors
+ - name: Setup example dev DB
+ working-directory: test/example
+ env:
+ MIX_ENV: dev
+ PGUSER: postgres
+ PGPASSWORD: postgres
+ PGHOST: localhost
+ run: mix ecto.create && mix ecto.migrate
+ - name: Install Playwright deps
+ working-directory: test/example/priv/playwright
+ run: npm ci
+ - name: Install Playwright browsers
+ working-directory: test/example/priv/playwright
+ run: npx playwright install --with-deps chromium
+ - name: Boot example app in background
+ working-directory: test/example
+ env:
+ MIX_ENV: dev
+ PGUSER: postgres
+ PGPASSWORD: postgres
+ PGHOST: localhost
+ PHX_SERVER: "true"
+ run: mix phx.server > /tmp/example-mfa-playwright-server.log 2>&1 &
+ - name: Wait for app
+ run: |
+ for i in $(seq 1 30); do
+ if curl -sf http://localhost:4000/ > /dev/null; then
+ echo "App responding after ${i}s"
+ break
+ fi
+ sleep 1
+ done
+ - name: Run MFA Playwright spec
+ working-directory: test/example/priv/playwright
+ env:
+ CI: "true"
+ SIGRA_EXAMPLE_URL: "http://localhost:4000"
+ run: npx playwright test tests/mfa-backup-rotation.spec.ts --project=chromium --reporter=line
+ - name: Generate MFA evidence report
+ # `mix sigra.uat.report` is a sigra-provided mix task. Run from
+ # test/example/ (where sigra is a path dep with deps already fetched)
+ # rather than the repo root (whose mix.exs has not had deps.get run
+ # in this job — that's why the previous "MIX_ENV=test" attempt failed
+ # with "the dependency is not available, run mix deps.get").
+ working-directory: test/example
+ env:
+ MIX_ENV: dev
+ SIGRA_CI_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
+ SIGRA_CI_WORKFLOW: .github/workflows/ci.yml / mfa_e2e_playwright
+ SIGRA_GIT_TAG: ${{ github.ref_name }}
+ run: MIX_ENV=dev mix sigra.uat.report --phase=mfa-backup-rotation
+ - name: Dump example app log (on failure)
+ if: failure()
+ run: |
+ echo "--- /tmp/example-mfa-playwright-server.log ---"
+ cat /tmp/example-mfa-playwright-server.log || echo "(no log file)"
+ - name: Upload MFA bundle (main, 14d retention)
+ if: always() && github.ref == 'refs/heads/main'
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+ with:
+ name: mfa-backup-rotation-bundle
+ path: .planning/uat-evidence/v1.20/mfa-backup-rotation/
+ retention-days: 14
+ - name: Upload MFA bundle (PR/push, 7d retention)
+ if: always() && github.ref != 'refs/heads/main' && !startsWith(github.ref, 'refs/tags/')
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+ with:
+ name: mfa-backup-rotation-bundle
+ path: .planning/uat-evidence/v1.20/mfa-backup-rotation/
+ retention-days: 7
+ - name: Upload MFA bundle (tag, 90d retention)
+ if: startsWith(github.ref, 'refs/tags/')
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+ with:
+ name: mfa-backup-rotation-bundle
+ path: .planning/uat-evidence/v1.20/mfa-backup-rotation/
+ retention-days: 90
+
generated_admin_playwright_smoke:
name: Generated admin Playwright smoke
runs-on: ubuntu-latest
@@ -924,3 +1449,179 @@ jobs:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Run Phase 34 mechanical UAT contracts
run: scripts/ci/phase34-uat-contracts.sh
+
+ # Phase 86: GAUAT-01 / GAUAT-02 — email visual regression harness
+ # Runs the snapshot prerender, evidence report generation, and narrow
+ # Playwright email-visual spec. Uploads the full raw bundle on every run.
+ # On any refs/tags/v* tag promotes that exact bundle to the matching GitHub release
+ # asset without rebuilding from different inputs (D-86-01, D-86-06, D-86-11).
+ #
+ # SEED-1/SEED-2 residual columns in docs/uat-ci-coverage.md point at this job.
+ # snapshot count = 36, contrast min ratio = 4.5, byte budget max = 100000
+ email_visual_regression:
+ name: Email visual regression (GAUAT-01/02)
+ runs-on: ubuntu-latest
+ permissions:
+ contents: write # required for gh release upload on tag runs
+ env:
+ CLOAK_KEY: MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=
+ services:
+ postgres:
+ image: postgres:15
+ env:
+ POSTGRES_PASSWORD: postgres
+ ports: ['5432:5432']
+ options: >-
+ --health-cmd pg_isready --health-interval 10s
+ --health-timeout 5s --health-retries 5
+ steps:
+ - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ - uses: erlef/setup-beam@fc68ffb90438ef2936bbb3251622353b3dcb2f93 # v1.24.0
+ with:
+ version-file: .tool-versions
+ version-type: strict
+ - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
+ with:
+ node-version: '20'
+ cache: 'npm'
+ cache-dependency-path: 'test/example/priv/playwright/package-lock.json'
+ - name: Cache library deps
+ uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
+ with:
+ path: |
+ deps
+ _build
+ key: ${{ runner.os }}-mix-test-${{ hashFiles('mix.lock', 'mix.exs') }}
+ - name: Cache example deps
+ uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
+ with:
+ path: |
+ test/example/deps
+ test/example/_build
+ key: ${{ runner.os }}-example-test-${{ hashFiles('test/example/mix.lock', 'test/example/mix.exs', 'mix.exs') }}
+ - name: Fetch library deps
+ run: mix deps.get
+ - name: Fetch example deps
+ working-directory: test/example
+ env:
+ MIX_ENV: test
+ run: mix deps.get
+ - name: Compile library (test env)
+ env:
+ MIX_ENV: test
+ run: mix compile --warnings-as-errors
+ - name: Compile example (test env)
+ working-directory: test/example
+ env:
+ MIX_ENV: test
+ run: mix compile --warnings-as-errors
+ - name: Setup test DB
+ working-directory: test/example
+ env:
+ MIX_ENV: test
+ PGUSER: postgres
+ PGPASSWORD: postgres
+ PGHOST: localhost
+ run: mix ecto.create && mix ecto.migrate
+ # L1: snapshot prerender — deterministic email HTML to disk via frozen fixtures
+ - name: Prerender email HTML snapshots (L1 snapshot lane)
+ env:
+ MIX_ENV: test
+ PGUSER: postgres
+ PGPASSWORD: postgres
+ PGHOST: localhost
+ run: MIX_ENV=test mix sigra.email.snapshot --check
+ # L2: Playwright email-visual spec — 36-cell matrix (9 templates × 2 engines × 2 themes)
+ - name: Install Playwright deps
+ working-directory: test/example/priv/playwright
+ run: npm ci
+ - name: Install Playwright browsers (chromium + webkit)
+ working-directory: test/example/priv/playwright
+ run: npx playwright install --with-deps chromium webkit
+ - name: Run email visual regression spec (L2 Playwright lane)
+ working-directory: test/example/priv/playwright
+ env:
+ CI: "true"
+ # Project names in playwright.config.ts are prefixed with `email-visual-`
+ # (see Available projects: in CI failure on run 25276147131). The
+ # short `email-*` names below were correct at one point but drifted
+ # from the config; the spec exits with "Project(s) ... not found".
+ run: npx playwright test tests/email-visual.spec.ts --project=email-visual-chromium-light --project=email-visual-chromium-dark --project=email-visual-webkit-light --project=email-visual-webkit-dark
+ # L3: evidence report generation — manifest + README + contrast/byte-budget reports
+ - name: Generate Phase 04 evidence report (L3 report lane)
+ env:
+ MIX_ENV: test
+ SIGRA_CI_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
+ SIGRA_CI_WORKFLOW: .github/workflows/ci.yml / email_visual_regression
+ SIGRA_GIT_TAG: ${{ github.ref_name }}
+ run: MIX_ENV=test mix sigra.uat.report --phase=04
+ - name: Generate Phase 08 evidence report (L3 report lane)
+ env:
+ MIX_ENV: test
+ SIGRA_CI_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
+ SIGRA_CI_WORKFLOW: .github/workflows/ci.yml / email_visual_regression
+ SIGRA_GIT_TAG: ${{ github.ref_name }}
+ run: MIX_ENV=test mix sigra.uat.report --phase=08
+ # Capture playwright test-results on spec failure for snapshot triage.
+ # The full evidence bundle below is assembled only on success because it
+ # depends on the L3 report steps that get skipped on L2 failure. Without
+ # this dedicated failure-path upload, snapshot drift is not triageable
+ # from the CI logs alone.
+ - name: Upload email visual test-results on failure
+ if: failure()
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+ with:
+ name: email-visual-failure-diagnostics
+ path: |
+ test/example/priv/playwright/test-results/
+ test/example/priv/playwright/playwright-report/
+ retention-days: 7
+ if-no-files-found: warn
+ # Bundle the full evidence payload: committed baselines + generated reports
+ - name: Assemble email evidence bundle
+ run: |
+ mkdir -p /tmp/email-visual-bundle
+ cp -r .planning/uat-evidence/v1.20/ /tmp/email-visual-bundle/uat-evidence/
+ cp -r test/example/priv/playwright/__snapshots__/email-visual.spec.ts/ /tmp/email-visual-bundle/baselines/
+ cp -r test/example/priv/email_snapshots/ /tmp/email-visual-bundle/html-snapshots/ 2>/dev/null || true
+ echo "Bundle contents:"
+ find /tmp/email-visual-bundle -type f | sort
+ # Upload the raw bundle artifact on every run (branch/PR and tags)
+ - name: Upload email visual regression bundle (main, 14d retention)
+ if: always() && github.ref == 'refs/heads/main'
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+ with:
+ name: email-visual-regression-bundle
+ path: /tmp/email-visual-bundle/
+ retention-days: 14
+ - name: Upload email visual regression bundle (PR/push, 7d retention)
+ if: always() && github.ref != 'refs/heads/main' && !startsWith(github.ref, 'refs/tags/')
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+ with:
+ name: email-visual-regression-bundle
+ path: /tmp/email-visual-bundle/
+ retention-days: 7
+ # On any v* tag (v1.20.0, v1.20.1, v1.21.0, ...) promote the exact same
+ # bundle to the matching GitHub release asset without rebuilding from
+ # different inputs (D-86-01, D-86-06).
+ - name: Create release asset archive (v* tag only)
+ if: startsWith(github.ref, 'refs/tags/v')
+ run: |
+ cd /tmp && tar -czf "sigra-email-visual-regression-${{ github.ref_name }}.tar.gz" email-visual-bundle/
+ echo "Release asset: sigra-email-visual-regression-${{ github.ref_name }}.tar.gz"
+ ls -lh "/tmp/sigra-email-visual-regression-${{ github.ref_name }}.tar.gz"
+ - name: Promote bundle to ${{ github.ref_name }} release asset
+ if: startsWith(github.ref, 'refs/tags/v')
+ env:
+ GH_TOKEN: ${{ github.token }}
+ run: |
+ gh release upload "${{ github.ref_name }}" "/tmp/sigra-email-visual-regression-${{ github.ref_name }}.tar.gz" \
+ --clobber \
+ --repo "${{ github.repository }}"
+ - name: Upload email visual bundle (tag, 90d retention)
+ if: startsWith(github.ref, 'refs/tags/')
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+ with:
+ name: email-visual-regression-bundle
+ path: /tmp/email-visual-bundle/
+ retention-days: 90
diff --git a/.github/workflows/hex-publish.yml b/.github/workflows/hex-publish.yml
index 8c2c4cf0..27bb7109 100644
--- a/.github/workflows/hex-publish.yml
+++ b/.github/workflows/hex-publish.yml
@@ -10,16 +10,16 @@ on:
workflow_dispatch:
inputs:
tag:
- description: 'Git tag or commit SHA to publish from (e.g. v0.2.1).'
+ description: 'Git tag or commit SHA to publish from (e.g. v1.20.0).'
required: true
type: string
release_version:
- description: 'Expected @version string in mix.exs at that ref (e.g. 0.2.1).'
+ description: 'Expected @version string in mix.exs at that ref (e.g. 1.20.0).'
required: true
type: string
permissions:
- contents: read
+ contents: write
jobs:
publish:
@@ -66,6 +66,16 @@ jobs:
- name: Verify release version in mix.exs
run: grep -n "@version \"${{ inputs.release_version }}\"" mix.exs
+ - name: Sync changelog summary into GitHub release body
+ env:
+ GH_TOKEN: ${{ github.token }}
+ GITHUB_REPOSITORY: ${{ github.repository }}
+ run: |
+ chmod +x scripts/release/sync_release_summary.sh
+ scripts/release/sync_release_summary.sh \
+ "${{ inputs.release_version }}" \
+ "${{ inputs.tag }}"
+
- name: Fetch library deps
run: mix deps.get
diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml
index 809b9b12..4f56b868 100644
--- a/.github/workflows/release-please.yml
+++ b/.github/workflows/release-please.yml
@@ -47,9 +47,31 @@ jobs:
config-file: release-please-config.json
manifest-file: .release-please-manifest.json
+ sync-release-summary:
+ name: Sync GitHub release summary
+ needs: release-please
+ if: ${{ needs.release-please.outputs.release_created == 'true' }}
+ runs-on: ubuntu-latest
+ permissions:
+ contents: write
+ steps:
+ - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ ref: ${{ needs.release-please.outputs.tag_name }}
+
+ - name: Sync changelog summary into GitHub release body
+ env:
+ GH_TOKEN: ${{ secrets.RELEASE_PLEASE_TOKEN || github.token }}
+ GITHUB_REPOSITORY: ${{ github.repository }}
+ run: |
+ chmod +x scripts/release/sync_release_summary.sh
+ scripts/release/sync_release_summary.sh \
+ "${{ needs.release-please.outputs.version }}" \
+ "${{ needs.release-please.outputs.tag_name }}"
+
publish-hex:
name: Publish to Hex.pm
- needs: release-please
+ needs: [release-please, sync-release-summary]
if: ${{ needs.release-please.outputs.release_created == 'true' }}
runs-on: ubuntu-latest
permissions:
diff --git a/.planning/AUDIT-ATOMICITY-DEFAULTS.md b/.planning/AUDIT-ATOMICITY-DEFAULTS.md
index 8503edd9..b2b09bae 100644
--- a/.planning/AUDIT-ATOMICITY-DEFAULTS.md
+++ b/.planning/AUDIT-ATOMICITY-DEFAULTS.md
@@ -34,7 +34,7 @@
### D-AUD-06 — Caller contract when audit insert fails (audit-only paths)
-- Public functions that today return **`:ok`** and use **`log_safe`/`log_multi_safe`** for **side-channel audit** keep **`:ok`** on audit subsystem failure; emit **`[:sigra, :audit, :log_safe_error]`** (or the same telemetry contract as `emit_log_safe_error`) so operators can alert; **raise** only on programmer-wiring errors. **`@doc`** must state **`:ok` does not guarantee** the audit row exists.
+- Public functions that today return **`:ok`** and use **`log_safe`/`log_multi_safe`** for **side-channel audit** keep **`:ok`** on audit subsystem failure **when the audit row is not co-fated with a durable partner write**. This covers three legitimate sub-classes: **detection-only** (the audit row is the forensic record), **pre-domain** (the event fires before a persistence target exists), and **audit-only helpers**. Emit **`[:sigra, :audit, :log_safe_error]`** (or the same telemetry contract as `emit_log_safe_error`) so operators can alert; **raise** only on programmer-wiring errors. **`@doc`** must state **`:ok` does not guarantee** the audit row exists.
### D-AUD-07 — ExUnit layout for audit fault injection
diff --git a/.planning/MILESTONE-ARC.md b/.planning/MILESTONE-ARC.md
new file mode 100644
index 00000000..236c5d9e
--- /dev/null
+++ b/.planning/MILESTONE-ARC.md
@@ -0,0 +1,170 @@
+---
+last_updated: 2026-05-08
+status: active
+current_release_followup: completed-REL-01
+default_post_release_candidate: EMAIL-RAILS
+---
+
+# Sigra Milestone Arc
+
+## Strategic Goal
+
+Make Sigra feel batteries-included for Phoenix teams until additional work becomes diminishing-return polish.
+
+This arc exists so milestone selection starts from a ranked strategic sequence instead of re-researching priorities every time `/gsd-new-milestone` runs.
+
+## Ranking Rules
+
+Prefer milestones that:
+
+1. Deepen already-shipped substrate instead of inventing new greenfield primitives.
+2. Remove production or integration friction that adopters hit immediately after install.
+3. Improve user/operator trust through clearer control surfaces, diagnostics, and honest docs.
+4. Keep Sigra core provider-agnostic and Phoenix-native.
+
+Deprioritize work that is mostly:
+
+- generic back-office admin expansion
+- hosted-control-plane imitation
+- product-specific authorization policy
+- compliance theater without executable seams or evidence
+
+## Ownership Boundaries
+
+**Sigra core owns:**
+- auth, session, passkey, and audit invariants
+- provider-agnostic contracts
+- diagnostics and verification hooks
+
+**Generated host code owns:**
+- user-facing and operator-facing UX
+- email composition/layout overrides
+- branding, copy, and host policy
+
+**Docs and optional integrations own:**
+- ESP-specific setup and bounce/complaint handling
+- SPF / DKIM / DMARC and sender reputation posture
+- compliance recipes and operator guidance
+- cross-device passkey operational guidance
+
+## GSD Defaults
+
+When milestone selection or roadmap triage is delegated:
+
+- default to the highest-ranked `candidate` below unless the user explicitly pivots
+- only escalate decisions that affect the public contract, semver, security model, generated-host contract, or milestone order
+- prefer decisive recommendations over reopening broad product-choice loops
+
+## Backlog Corrections
+
+The carried-forward future-requirement labels are not equally fresh. Treat these as corrected before planning the next milestone:
+
+- `SESS-01` is effectively already shipped through session/device labeling in the generated host session surface.
+- `PK-01` is mostly already shipped through passkey list / rename / remove flows.
+- `EMAIL-02` should mean a coherent localization workflow and override seam, not merely that gettext calls exist.
+
+The remaining meaningful work clusters are:
+
+- email reliability, override seams, and diagnostics
+- passkey recovery and cross-device trust
+- compliance-friendly export and data-lifecycle seams
+
+## Candidates
+
+### active-followup
+
+**Name:** `REL-01 Release Truth Reset`
+**Priority:** required-before-new-feature-milestone
+**Why now:** The repo has a milestone boundary but still needs a single coherent release/version story across package metadata, changelog, and release automation.
+**Includes:**
+- reconcile `mix.exs`, `.release-please-manifest.json`, `CHANGELOG.md`, and maintainer release docs
+- verify the next release cut can be explained without mixing planning milestones with Hex versioning
+**Non-goals:**
+- new feature work
+- reopening webhook trust work
+- redesigning semver policy beyond restoring one coherent truth
+
+### shipped
+
+**ID:** `SESS-CTRL`
+**Name:** `Session Control Plane`
+**Priority:** 1
+**Status:** Shipped 2026-05-08 via Phases 108-110.
+**Why it mattered:** Best leverage-to-risk ratio; strengthened trust for every adopter using mostly shipped primitives.
+**Theme:** Turn existing session primitives into a coherent account-security surface.
+**Delivered scope:**
+- logout-other-sessions-except-current
+- clearer current-session truth on user/admin surfaces
+- recent security activity over persisted audit/session truth
+- cleaner revoke UX and bounded docs truth
+**Bounded non-goals held:** no session-store redesign, no generic account-center expansion, no timeout-history over-claim.
+
+### active-milestone
+
+**ID:** `EMAIL-RAILS`
+**Name:** `Email Reliability & Override Rails`
+**Priority:** 1
+**Why now:** Phoenix still leaves email integration rough edges to the host; Sigra can make the default production path legible without claiming to own deliverability itself.
+**Theme:** Make auth email integration production-ready after install.
+**Likely scope:**
+- generated-host override seam for auth email templates
+- preview and snapshot rails
+- diagnostics and doctor checks for missing or inconsistent setup
+- provider-agnostic telemetry and async delivery posture
+- bounce / complaint hooks or stubs plus recipes
+**Prerequisites:**
+- keep core provider-agnostic
+- preserve Swoosh and Oban seams
+**Non-goals:**
+- owning SPF / DKIM / DMARC
+- hard-coding a preferred ESP in core
+- becoming a generic inbound email-processing platform
+
+### candidate
+
+**ID:** `PK-LIFECYCLE`
+**Name:** `Passkey Lifecycle Completion`
+**Priority:** 2
+**Why now:** Passkey ceremony and basic management exist; the remaining risk is recovery and lifecycle trust.
+**Theme:** Make passkey-primary and multi-device use trustworthy instead of just technically functional.
+**Likely scope:**
+- recovery-first passkey-primary posture
+- last-passkey warnings
+- cross-device and bootstrap guidance/UX
+- tighter lifecycle integration around passkeys
+**Prerequisites:**
+- stable fallback and recovery story
+- browser/platform validation for real flows
+**Non-goals:**
+- removing fallback auth by default
+- inventing a custom sync layer
+- broad MFA rewrite outside passkey-specific paths
+
+### candidate
+
+**ID:** `DATA-LIFECYCLE`
+**Name:** `Compliance Export & Data Lifecycle`
+**Priority:** 3
+**Why now:** Valuable, but lower-frequency adopter pain than session and email rough edges.
+**Theme:** Extend existing export and anonymize seams into a coherent auth-data lifecycle story.
+**Likely scope:**
+- extend `Sigra.DataExport`
+- include audit-log export posture
+- clarify anonymize/delete semantics and operator recipes
+**Prerequisites:**
+- keep exports narrowly scoped to auth/account data
+- reuse existing audit and admin export substrate
+**Non-goals:**
+- legal or compliance certification
+- generic BI/reporting exports
+- claiming host-app regulatory ownership
+
+## Selection Guidance
+
+`REL-01 Release Truth Reset` is complete. Until a stronger signal appears from real adopter feedback, use this default sequence:
+
+1. `EMAIL-RAILS Email Reliability & Override Rails`
+2. `PK-LIFECYCLE Passkey Lifecycle Completion`
+3. `DATA-LIFECYCLE Compliance Export & Data Lifecycle`
+
+If a future milestone proposal does not clearly advance production trust, integration clarity, or DX on rough edges, treat it as lower priority than the ranked candidates above.
diff --git a/.planning/MILESTONES.md b/.planning/MILESTONES.md
index ecf70ea1..dd35cbad 100644
--- a/.planning/MILESTONES.md
+++ b/.planning/MILESTONES.md
@@ -585,3 +585,193 @@
- [v1.15 Requirements](milestones/v1.15-REQUIREMENTS.md)
---
+
+## v1.20 GA Launch (SEED closure + public release) (Shipped: 2026-04-28)
+
+**Scope:** 6 phases (**85–90**), 14 on-disk plans. (Phase 90 waived).
+
+**What shipped:** **AUD-21** — OAuth audit atomicity closure, converting remaining `log_safe/3` clusters in Phase 45 T2 to atomic `Repo.transaction/1` + `Ecto.Multi`. **GAUAT-01..09** — Fully automated E2E harnesses for email visual QA, OAuth real-credential cycles, MFA backup-code rotation, and getting-started proof, resulting in SEED-001 closure. **LAUNCH-01..07** — Hex v1.20.0 publish, README promotion, and CHANGELOG alignment.
+
+### Key accomplishments
+
+1. **AUD-21 closure** — Phase 9 C-1 caveat officially downgraded to PASS.
+2. **GAUAT zero-human proof** — Replaced all manual SEED-001 testing requirements with deterministic CI automation (Playwright + Premailex).
+3. **v1.20.0 Public Launch** — Reached the "use this in production" inflexion point.
+
+### Stats
+
+- **Requirements:** 21/21 requirements satisfied/waived.
+- **Milestone audit:** **passed** ([`milestones/v1.20-MILESTONE-AUDIT.md`](milestones/v1.20-MILESTONE-AUDIT.md)).
+- **Timeline:** 2026-04-25 → 2026-04-28.
+
+### Tech debt carried forward
+
+- Lockspire glue package deferred.
+- Week-one launch-feedback follow-ups deferred to patch milestone.
+
+**Archive:**
+
+- [v1.20 Roadmap](milestones/v1.20-ROADMAP.md)
+- [v1.20 Requirements](milestones/v1.20-REQUIREMENTS.md)
+- [v1.20 Milestone Audit](milestones/v1.20-MILESTONE-AUDIT.md)
+
+---
+
+## v1.21 B2B-ready & production-honest (Shipped: 2026-05-06)
+
+**Scope:** 6 phases (**91–96**), 33 on-disk plan summaries (across 26 PLAN.md files; some phases inline-summarized).
+
+**What shipped:** First milestone after v1.20 public launch. Three legs converged. **Leg 1 — B2B trust** (Phases **91**, **92**, **93**) — `Sigra.Plug.RequireOrgMfa` + `enforce_mfa_for_members` + admin LiveView toggle + atomic `organization.mfa_policy_change` audit row (**B2B-01**); `Sigra.Authz` `can?/3` behaviour + nullable `role` on `OrganizationMembership` + scope-struct `:role` propagation + role-based-access-control recipe (zero opinionated roles in `lib/sigra/`) (**B2B-02**); org-scoped service-account tokens via `client_credentials` grant on existing JWT path + `current_scope.actor_type: :service_account` discriminator + 5 SA-mutation rollback proofs (**B2B-03**, re-verified 22/22 after gap-closure plans 06–10 + critical fixes in commit `bf5a8a8`). **Leg 2 — Production hardening** (Phases **94**, **95**) — `mix sigra.install` refuses non-Postgres adapter at pre-flight + removed MySQL/SQLite placeholder branches + aligned `mix.exs` description / README / getting-started narrative; environmental Oban-test caveat closed in 2026-05-06 audit (**HARD-01**); `Sigra.OptionalDeps` SOT + raise-on-missing for Oban/Bcrypt/EQRCode + `mix sigra.doctor` per-feature dep matrix + 3 dep-off CI lanes (**HARD-02**, only v1.21 phase with `nyquist_compliant: true`). **Leg 3 — OAuth + API polish** (Phase **96**) — per-provider OAuth refresh dispatch for GitHub/Apple/Facebook/Generic via Assent + atomic `oauth.token_refreshed` audit (**HARD-03**); single-pass `Sigra.Plug.RateLimit` emitting `X-RateLimit-Limit/Remaining/Reset` + `Retry-After` from Hammer state, wired into generated host's `:auth_rate_limit` pipeline (**API-01**) — 122 passing tests across 4 evidence sections.
+
+### Key accomplishments
+
+1. **Org-level MFA enforcement** — Atomic policy-change audit + plug + LiveView gate; full library suite green (33 doctests, 3 properties, 2214 tests, 0 failures).
+2. **RBAC seams without opinions** — `Sigra.Authz` ships as behaviour-only; library has zero `:owner / :admin / :member` constants; recipe is the only place those names appear, illustratively.
+3. **M2M service-account tokens** — `client_credentials` grant on existing JWT path; scope-struct `actor_type` discriminator; SA short-circuits user-membership and org-MFA checks; 5/5 mutations co-fated with audit (D-AUD-08).
+4. **Honest Postgres-only narrative** — Aligned the documented adapter support to what CI actually exercises and what migrations actually implement.
+5. **Optional-dep boot validation** — `mix sigra.doctor` reports per-feature status; missing optional deps raise tagged errors at first use instead of compiling to silent `nil`; CI matrix toggles each off.
+6. **OAuth refresh dispatch + rate-limit headers** — Closed the `lib/sigra/oauth.ex:174` "not yet implemented" warning across 4 providers with atomic audit; clients on rate-limited paths get standards-compliant headers for backoff.
+
+### Stats
+
+- **Requirements:** 7/7 requirements satisfied (B2B-01, B2B-02, B2B-03, HARD-01, HARD-02, HARD-03, API-01).
+- **Milestone audit:** **tech_debt → reconciled** ([`milestones/v1.21-MILESTONE-AUDIT.md`](milestones/v1.21-MILESTONE-AUDIT.md)). Substantive 7/7 with passing test evidence; bookkeeping reconciled 2026-05-06.
+- **Timeline:** 2026-04-28 → 2026-05-06 (8 days).
+- **Cross-phase wires verified:** B2B-02 `:actor_type` reservation → B2B-03 `:service_account` population; B2B-02 host-supplied `:roles` → B2B-03 SA short-circuit; HARD-01 Postgres-only → HARD-02 `mix sigra.doctor`; HARD-03 OAuth refresh → API-01 rate-limit headers.
+
+### Known deferred items at close (non-blocking)
+
+- 2 install-smoke pending todos from 2026-04-30: JOSE.JWT.peek_payload/1 undefined warning + transient Postgres `too_many_connections` during install smoke (both surfaced during Phase 94 work).
+- `DEF-92-02-01` — InvitationAcceptLive audit-Multi-step name collision (pre-existing bug from commit `5e6c026`, predates Phase 92; recommended landing point not yet assigned).
+- Nyquist VALIDATION.md gaps — only Phase 95 has `nyquist_compliant: true`; 91/92/93 have draft VALIDATION.md (`nyquist_compliant: false`); 94/96 missing entirely. Optional retroactive fill via `/gsd-validate-phase`.
+
+### Tech debt carried forward
+
+- Webhooks (`WH-01..03`) — deferred to v1.22 as its own design-first milestone (event schema, signed delivery, retry/dead-letter, host UX).
+- Tier-3 polish carried in Future Requirements: Session UX (`SESS-01..03`), Email overrides + i18n + bounce (`EMAIL-01..03`), Passkey multi-authenticator + recovery (`PK-01..03`), DataExport depth (`DATA-01..03`).
+- `sigra_lockspire` glue package per **ADR 001** — still awaiting companion-app trigger.
+
+**Archive:**
+
+- [v1.21 Roadmap](milestones/v1.21-ROADMAP.md)
+- [v1.21 Requirements](milestones/v1.21-REQUIREMENTS.md)
+- [v1.21 Milestone Audit](milestones/v1.21-MILESTONE-AUDIT.md)
+
+---
+
+## v1.22 Webhooks / outbound event pipeline (Shipped: 2026-05-06)
+
+**Scope:** 6 phases (**97–102**), 20 on-disk plans.
+
+**What shipped:** Sigra now emits real outbound auth and identity webhooks as a first-party product surface. **Phase 97** established the event contract, durable subscription registry, stable payload envelope, and HMAC signing contract. **Phase 98** added persisted attempts, bounded retries, and dead-letter state so delivery reliability no longer depends on raw Oban semantics. **Phase 99** exposed the capability through generated admin LiveViews, routes, and adopter-facing guidance. After the first milestone audit found end-to-end gaps, **Phase 100** restored the production enqueue handoff from persisted delivery rows into the async worker path, **Phase 101** corrected operator-state query truth for retrying and dead-lettered views, and **Phase 102** proved the generated-host flow end to end while reconciling roadmap, requirements, state, and verification artifacts.
+
+### Key accomplishments
+
+1. **Stable webhook contract** — durable subscription registry, canonical event catalog, public payload serializers, and documented HMAC verification contract for Sigra-owned auth and identity events.
+2. **Reliable delivery pipeline** — persisted summary rows, append-only attempt history, bounded retries, and durable dead-letter state.
+3. **Generated-host operator UX** — admin LiveViews for subscription management, delivery history, failure inspection, and secret rotation.
+4. **Production handoff repaired** — persisted delivery rows now enqueue the first worker job automatically from the mutation path instead of stalling before async dispatch.
+5. **Operator truth restored** — retrying and dead-lettered views now match persisted delivery state before pagination.
+6. **Adopter proof closed** — generated-host evidence correlates receiver-side verification with admin-visible delivery history and reconciled planning artifacts.
+
+### Stats
+
+- **Requirements:** 3/3 requirements satisfied (`WH-01..03`).
+- **Milestone audit:** historical `gaps_found` audit preserved and superseded by [`102-VERIFICATION.md`](phases/102-generated-host-proof-and-planning-reconciliation/102-VERIFICATION.md) after Phases 100–102 closed the listed gaps.
+- **Pre-close `audit-open`:** all artifact types clear on 2026-05-07 after resolving quick-task metadata drift and the two install-smoke todos.
+- **Git (milestone range):** first milestone commit `6b8ef36` on 2026-05-06; current diff vs that start point is `43` files changed, `5833` insertions, `32` deletions.
+
+### Tech debt carried forward
+
+- Webhook follow-ons remain future work only: replay support, safer secret-rotation windows, and tighter outbound egress controls (`WH-04..06`).
+- Tier-3 polish stays deferred: session UX completeness, email overrides + i18n + bounce handling, passkey multi-authenticator + recovery, and DataExport depth.
+- `sigra_lockspire` glue package per **ADR 001** remains trigger-based.
+- Nyquist VALIDATION.md coverage remains thin for earlier B2B phases; not part of the webhook milestone contract.
+
+**Archive:**
+
+- [v1.22 Roadmap](milestones/v1.22-ROADMAP.md)
+- [v1.22 Requirements](milestones/v1.22-REQUIREMENTS.md)
+- [v1.22 Milestone Audit](milestones/v1.22-MILESTONE-AUDIT.md)
+
+---
+
+## v1.23 Webhook operator trust & controls (Shipped: 2026-05-08)
+
+**Scope:** 5 phases (**103–107**), 16 on-disk plans.
+
+**What shipped:** v1.23 closes the three operational trust gaps left after the outbound webhook pipeline launch. **Phase 103** replaced one-shot signing-secret rotation with a dual-slot lifecycle, overlap-window signatures, truthful admin controls, and generated-host proof (**WH-04**). **Phase 104** implemented replay as a new delivery lineage with durable parent/root pointers, admin recovery actions, LiveView lineage truth, and generated-host proof; **Phase 106** then turned that evidence into authoritative milestone verification via `104-VERIFICATION.md` (**WH-05**). **Phase 105** implemented enforceable endpoint policy, generated-host policy seams, and deployment guidance; **Phase 107** finished the blocked-policy admin truth, denied-path browser proof, and repaired-form `105-VERIFICATION.md` / `105-VALIDATION.md` closeout (**WH-06**).
+
+### Key accomplishments
+
+1. **Overlap-safe secret rotation** — webhook subscriptions can carry current and next secrets through a bounded overlap window without delivery loss or replay-contract drift.
+2. **Replay as truthful recovery** — operators can replay dead-lettered deliveries as fresh child rows with new `delivery_id` values while preserving the original failed history and attempt ledger.
+3. **Enforceable outbound policy** — Sigra can deny disallowed webhook destinations locally before egress and preserve canonical `policy_reason` / `policy_detail` truth across worker, admin, and proof surfaces.
+4. **Generated-host evidence is now adopter-grade** — rotation lifecycle, replay recovery, and blocked-policy operator inspection all have durable `.planning/uat-evidence/v1.23/*` bundles.
+5. **Milestone audit closed cleanly** — `WH-04..06` are all satisfied, `104-VERIFICATION.md` and `105-VERIFICATION.md` exist, and the live v1.23 audit now passes.
+
+### Stats
+
+- **Requirements:** 3/3 requirements satisfied (`WH-04`, `WH-05`, `WH-06`).
+- **Milestone audit:** passed at close ([`milestones/v1.23-MILESTONE-AUDIT.md`](milestones/v1.23-MILESTONE-AUDIT.md)).
+- **Pre-close `audit-open`:** all artifact types clear (2026-05-08).
+- **Timeline:** 2026-05-07 → 2026-05-08.
+- **Worktree delta from milestone start commit `200e131`:** 63 tracked files changed, 6131 insertions, 557 deletions.
+
+### Known deferred items at close
+
+- `REL-01` release-cut work is intentionally deferred to the next milestone now that webhook operator trust is closed honestly.
+- Tier-3 follow-ons remain future work only: session UX, email overrides and i18n, passkey polish, and data-export depth.
+- `sigra_lockspire` glue package per **ADR 001** remains trigger-based and out of scope for this milestone.
+
+### Technical debt carried forward
+
+- The repository was still on a dirty worktree at milestone close, so the planning archive is complete but the git closeout commit and release tag must be cut only after the shipped code and docs land in clean commits.
+
+**Archive:**
+
+- [v1.23 Roadmap](milestones/v1.23-ROADMAP.md)
+- [v1.23 Requirements](milestones/v1.23-REQUIREMENTS.md)
+- [v1.23 Milestone Audit](milestones/v1.23-MILESTONE-AUDIT.md)
+
+---
+
+## v1.24 Session Control Plane (Shipped: 2026-05-08)
+
+**Scope:** 3 phases (**108–110**), 9 on-disk plans.
+
+**What shipped:** v1.24 turned Sigra's session and audit substrate into a coherent account-security control plane. **Phase 108** shipped preserve-current revoke semantics, truthful current-session labeling, and aligned user/admin/docs behavior for session truth (**SESS-02**, first `SESS-04/05` slice). **Phase 109** shipped the library-owned recent-security-activity seam plus explicit logout/MFA activity truth across generated-host, admin, and docs surfaces (**SESS-03**, remaining `SESS-04/05`). **Phase 110** converted the implementation summary chain into authoritative `108-VERIFICATION.md` and `109-VERIFICATION.md` artifacts, then reconciled the active milestone truth across planning files and the live audit.
+
+### Key accomplishments
+
+1. **Preserve-current revoke is now first-class** — users can revoke sibling sessions without losing the current device, and the operation fails closed if the preserved session cannot be proven.
+2. **Current-session truth is authoritative** — user and admin surfaces derive the current session from persisted/session-token truth rather than LiveView heuristics or raw-token comparisons.
+3. **Recent security activity is now Sigra-owned** — sign-in, suspicious-login, logout, revoke, and MFA verification render through a canonical library seam over persisted audit rows.
+4. **Thin-host boundaries held** — generated hosts delegate session-control and activity logic to Sigra-owned seams instead of reimplementing business rules.
+5. **Milestone proof is repaired and archive-ready** — `108-VERIFICATION.md`, `109-VERIFICATION.md`, and `v1.24-MILESTONE-AUDIT.md` now provide a coherent authoritative closeout surface.
+
+### Stats
+
+- **Requirements:** 4/4 requirements satisfied (`SESS-02`, `SESS-03`, `SESS-04`, `SESS-05`).
+- **Milestone audit:** passed at close ([`milestones/v1.24-MILESTONE-AUDIT.md`](milestones/v1.24-MILESTONE-AUDIT.md)).
+- **Pre-close `audit-open`:** all artifact types clear (2026-05-08).
+- **Timeline:** 2026-05-08.
+- **Scoped worktree delta at close:** 17 files changed, 1355 insertions, 243 deletions across the tracked session-control implementation and planning surfaces.
+
+### Known deferred items at close
+
+- `EMAIL-RAILS` is now the default next milestone candidate; it was intentionally not pulled into the v1.24 scope.
+- `PK-LIFECYCLE` and `DATA-LIFECYCLE` remain ranked follow-ons, not hidden v1.24 gaps.
+- Historical Nyquist coverage thin spots from older milestones remain non-blocking carried debt, not session-control misses.
+
+### Technical debt carried forward
+
+- The repository is still on a dirty worktree at milestone close. The planning archive can be committed selectively, but a release-accurate `v1.24` git tag must wait until the shipped implementation and proof changes are committed cleanly.
+
+**Archive:**
+
+- [v1.24 Roadmap](milestones/v1.24-ROADMAP.md)
+- [v1.24 Requirements](milestones/v1.24-REQUIREMENTS.md)
+- [v1.24 Milestone Audit](milestones/v1.24-MILESTONE-AUDIT.md)
+
+---
diff --git a/.planning/PROJECT.md b/.planning/PROJECT.md
index 4711de87..d302e4f4 100644
--- a/.planning/PROJECT.md
+++ b/.planning/PROJECT.md
@@ -18,9 +18,54 @@ Milestone scoping for GSD (`/gsd-new-milestone`, `/gsd-plan-phase`) should prefe
**GSD use:** When a phase or milestone proposal does not clearly move one of the bullets above, treat it as lower priority unless it closes a documented adoption gap or security/audit risk.
-## Current milestone
+**GSD preference:** When the user delegates architecture or product tradeoffs, default to researched decisive recommendations and only escalate choices that materially alter the security model, the public/semver contract, or the generated-host contract. Implementation-level forks should usually be resolved by the agent without reopening broad decision loops.
-**v1.19 — JWT refresh persistence + audit co-fate & MFA enrollment failure (SEED-002)** — **Phases 82–83** (opened **2026-04-24**). Closes the **v1.18** footnote deferral: **JWT `user_tokens` rotation** (`Sigra.JWT.RefreshToken` / **`Sigra.JWT.refresh/3`**) must share a **single transactional boundary** with **`api.jwt_refresh`** / **`api.jwt_refresh_reuse`** audit rows when `:audit_schema` is set (no successful persistence with a missing audit row, and no audit row for a rolled-back rotation). Second tranche: **`AUD-04-022`** / **`EX-44-02`** — invalid pre-DB TOTP on **`Sigra.MFA.confirm_enrollment/5`** promoted to the same **`Multi` + `log_multi_safe`** discipline where semantics allow. Live **`.planning/REQUIREMENTS.md`** + **`.planning/ROADMAP.md`**.
+## Latest Shipped Milestone: v1.24 Session Control Plane
+
+**Shipped:** 2026-05-08
+
+Sigra now ships a coherent account-security control plane on top of its existing session and audit substrate: preserve-current revoke semantics, truthful current-session labeling, recent security activity over persisted audit rows, and repaired-form milestone proof that keeps the generated host, admin surfaces, docs, and planning artifacts aligned.
+
+Archives:
+- [`.planning/milestones/v1.24-ROADMAP.md`](milestones/v1.24-ROADMAP.md)
+- [`.planning/milestones/v1.24-REQUIREMENTS.md`](milestones/v1.24-REQUIREMENTS.md)
+- [`.planning/milestones/v1.24-MILESTONE-AUDIT.md`](milestones/v1.24-MILESTONE-AUDIT.md)
+
+## Current State
+
+v1.24 closes the highest-leverage post-webhook trust gap for everyday adopters. Users can now revoke all other sessions without losing the current device, both user and admin surfaces derive current-session truth from authoritative persisted state, and recent security activity reflects the real audit/session lifecycle Sigra already owns.
+
+The active requirement set for v1.24 has been archived. The next milestone should start from a fresh `REQUIREMENTS.md`, not by extending the shipped session-control scope in place.
+
+## Next Milestone Goals
+
+- `SESS-CTRL` is now shipped and should not be replanned as open feature work.
+- Follow the ranked milestone arc in [`.planning/MILESTONE-ARC.md`](MILESTONE-ARC.md) instead of re-researching candidate themes from scratch.
+- Default next milestone order: email reliability/override rails first, then passkey lifecycle completion, then compliance export/data lifecycle.
+- Treat stale carried-forward labels carefully: `SESS-01` and most of `PK-01` are already substantially shipped, so future milestones should focus on remaining trust and lifecycle gaps rather than replanning completed surfaces.
+
+## Current Milestone Status
+
+No active milestone requirements are defined right now.
+
+The next milestone should begin with a fresh `REQUIREMENTS.md`. Unless the user explicitly pivots, the ranked default is `EMAIL-RAILS` from [`.planning/MILESTONE-ARC.md`](MILESTONE-ARC.md).
+
+### Just shipped: v1.24 Session Control Plane
+
+- preserve-current revoke semantics via a library-owned `revoke_other_sessions` seam
+- truthful current-session labeling across user and admin surfaces
+- recent security activity over persisted audit rows
+- repaired-form verification artifacts for Phases 108-109 and a passing live milestone audit
+
+### Previously closed milestones
+
+**v1.22 — Webhooks / outbound event pipeline** — **Phases 97–102** (shipped **2026-05-06**). Phase **97** established the public event catalog, durable subscription registry, stable payload envelope, and signing contract. Phase **98** made delivery reliable with persisted attempts, bounded retries, and dead-letter state. Phase **99** turned that capability into a usable adopter feature through generated admin LiveViews, routing, and host guidance. Gap-closure Phase **100** restored the production enqueue handoff from persisted delivery rows into the async worker path, Phase **101** made retrying/dead-lettered operator views truthful, and Phase **102** proved the generated-host flow end to end while reconciling `ROADMAP.md`, `REQUIREMENTS.md`, `STATE.md`, and verification artifacts. Archives: [`.planning/milestones/v1.22-ROADMAP.md`](milestones/v1.22-ROADMAP.md), [`v1.22-REQUIREMENTS.md`](milestones/v1.22-REQUIREMENTS.md), [`v1.22-MILESTONE-AUDIT.md`](milestones/v1.22-MILESTONE-AUDIT.md).
+
+**v1.21 — B2B-ready & production-honest** — **Phases 91–96** (shipped **2026-05-06**). Three legs converged: **B2B trust** — Phase **91** org-level MFA enforcement (**B2B-01**) with `Sigra.Plug.RequireOrgMfa` + atomic `organization.mfa_policy_change` audit row, Phase **92** RBAC seams (**B2B-02**) shipping `Sigra.Authz` behaviour + nullable `role` on memberships + scope-struct `:role` propagation + role-based-access-control recipe (zero opinionated roles), Phase **93** M2M / service-account tokens (**B2B-03**) with `client_credentials` grant on existing JWT path + `current_scope.actor_type: :service_account` discriminator + 5 SA-mutation rollback proofs (re-verified 22/22 after gap-closure plans 06–10 + critical fixes in commit `bf5a8a8`). **Production hardening** — Phase **94** Postgres-only declaration (**HARD-01**) refusing non-Postgres at `mix sigra.install` pre-flight + removed MySQL/SQLite placeholder branches + aligned `mix.exs` description / README / getting-started (env Oban-test caveat closed 2026-05-06), Phase **95** optional-dep boot validation (**HARD-02**) via `Sigra.OptionalDeps` SOT + raise-on-missing for Oban/Bcrypt/EQRCode + `mix sigra.doctor` per-feature dep matrix + 3 dep-off CI lanes (only v1.21 phase with `nyquist_compliant: true`). **OAuth + API polish** — Phase **96** OAuth refresh dispatch (**HARD-03**) for GitHub/Apple/Facebook/Generic via Assent + atomic `oauth.token_refreshed` audit + rate-limit headers (**API-01**) emitting `X-RateLimit-Limit/Remaining/Reset` + `Retry-After` from Hammer state in single-pass plug (122 passing tests across 4 evidence sections). Audit: tech_debt → reconciled (substantive 7/7; bookkeeping reconciled 2026-05-06). Open at close (non-blocking): 2 install-smoke todos from 2026-04-30, `DEF-92-02-01` pre-existing audit Multi step-name collision (predates Phase 92), Nyquist VALIDATION.md gaps for 91/92/93/94/96. Archives: [`.planning/milestones/v1.21-ROADMAP.md`](milestones/v1.21-ROADMAP.md), [`v1.21-REQUIREMENTS.md`](milestones/v1.21-REQUIREMENTS.md), [`v1.21-MILESTONE-AUDIT.md`](milestones/v1.21-MILESTONE-AUDIT.md).
+
+**v1.20 — GA Launch (SEED closure + public release)** — **Phases 85–90** (shipped **2026-04-28**). Closed **SEED-002** OAuth audit atomicity remainder (Phase **45 T2** clusters **052–056**, **058**, **063** to atomic **`Multi` + `log_multi_safe`**; Phase 9 **C-1 PASS-WITH-CAVEATS → PASS**). Closed **SEED-001** GA UAT — all 8 rows machine-substituted via Playwright + Premailex (**GAUAT-01..09**) with evidence under **`.planning/uat-evidence/v1.20/`**. Public launch via **`mix hex.publish`** v1.20.0 + README "use this in production" promotion + CHANGELOG alignment (**LAUNCH-01..07**). **Phase 90** publicity / monitoring waived. Archives: [`.planning/milestones/v1.20-ROADMAP.md`](milestones/v1.20-ROADMAP.md), [`v1.20-REQUIREMENTS.md`](milestones/v1.20-REQUIREMENTS.md), [`v1.20-MILESTONE-AUDIT.md`](milestones/v1.20-MILESTONE-AUDIT.md).
+
+**v1.19 — JWT refresh persistence + audit co-fate & MFA enrollment failure (SEED-002)** — **Phases 82–83** (shipped **2026-04-24**). Closed the **v1.18** footnote deferral: **JWT `user_tokens` rotation** (`Sigra.JWT.RefreshToken` / **`Sigra.JWT.refresh/3`**) shares a **single transactional boundary** with **`api.jwt_refresh`** / **`api.jwt_refresh_reuse`** audit rows when `:audit_schema` is set. Second tranche: **`AUD-04-022`** / **`EX-44-02`** — invalid pre-DB TOTP on **`Sigra.MFA.confirm_enrollment/5`** promoted to the same **`Multi` + `log_multi_safe`** discipline where semantics allow. Plus **Phase 84** routing-honesty reconciliation (**2026-04-25**).
**Previously closed:** **v1.18 — JWT refresh / reuse audit atomicity (SEED-002 / AUD-04-048..049 / AUD-18)** (**Phase 81**, **AUD-18-01**..**AUD-18-04**, **2026-04-24**). **`Sigra.APIToken.audit_jwt_refresh/2`** / **`audit_jwt_refresh_reuse/2`** use **`Repo.transaction/1`** + audit-only **`Multi` + `log_multi_safe`** when `:audit_schema` is set; **`api_token_audit_atomic_test.exs`**; **44** / **45** / **09** / **`CHANGELOG` [Unreleased]**; **JWT persistence co-fate** explicitly deferred to **v1.19**. Verification: **`.planning/phases/81-jwt-refresh-audit-atomicity/81-VERIFICATION.md`**.
@@ -48,7 +93,15 @@ Milestone scoping for GSD (`/gsd-new-milestone`, `/gsd-plan-phase`) should prefe
## Current State
-**v1.19 (shipped 2026-04-24):** Phases **82–83** — **AUD-19** (JWT **`user_tokens`** persistence + **`api.jwt_refresh*`** co-fate) + **AUD-20** (**`AUD-04-022`** invalid-code → **`commit_ad_hoc_mfa_audit/5`**). **`83-VERIFICATION.md`** / **`82-VERIFICATION.md`** merge gates; live **`REQUIREMENTS.md`** / **`ROADMAP.md`**.
+**v1.23 (shipped 2026-05-08):** Phases **103–107** closed the webhook operator-trust follow-ons to v1.22. Phase **103** shipped overlap-safe secret rotation with a dual-slot lifecycle and overlap-window signatures (**WH-04**). Phase **104** implemented replay recovery as new delivery lineage, and Phase **106** authoritatively verified that recovery path through `104-VERIFICATION.md` (**WH-05**). Phase **105** implemented webhook egress policy enforcement, and Phase **107** closed the remaining operator-truth and evidence gap for `WH-06` through `105-VERIFICATION.md`, `105-VALIDATION.md`, and the blocked-policy proof bundle under `.planning/uat-evidence/v1.23/webhook-policy-operator-truth/`.
+
+**v1.22 (shipped 2026-05-06):** Phases **97–102** delivered the outbound event pipeline: signed event contract, durable subscription registry, bounded retries, dead-letter state, generated admin UX, production enqueue repair, operator-truth queries, and generated-host proof.
+
+**v1.21 (shipped 2026-05-06):** Phases **91–96** — B2B trust + production hardening + API polish. Org-level MFA enforcement (**B2B-01**, Phase 91), RBAC seams (**B2B-02**, Phase 92), M2M service-account tokens (**B2B-03**, Phase 93, re-verified 22/22), Postgres-only declaration (**HARD-01**, Phase 94), optional-dep boot validation + `mix sigra.doctor` (**HARD-02**, Phase 95), OAuth refresh + rate-limit headers (**HARD-03 + API-01**, Phase 96). Audit: tech_debt → reconciled. Phase numbering continues from **Phase 96**. Archives: [`.planning/milestones/v1.21-ROADMAP.md`](milestones/v1.21-ROADMAP.md), [`v1.21-REQUIREMENTS.md`](milestones/v1.21-REQUIREMENTS.md), [`v1.21-MILESTONE-AUDIT.md`](milestones/v1.21-MILESTONE-AUDIT.md).
+
+**v1.20 (shipped 2026-04-28):** Phases **85–90** — **AUD-21** (OAuth audit atomicity closure: Phase 45 T2 clusters 052–056 / 058 / 063 → atomic `Multi`; Phase 9 **C-1 PASS-WITH-CAVEATS → PASS**), **GAUAT-01..09** (machine substitutes for all 8 SEED-001 rows via Playwright + Premailex; evidence at `.planning/uat-evidence/v1.20/`), **LAUNCH-01..07** (Hex v1.20.0 push + README promotion + CHANGELOG alignment). Phase 90 publicity / monitoring waived. Verification: `.planning/phases/89-pre-launch-hex-publish/`, milestone audit `.planning/milestones/v1.20-MILESTONE-AUDIT.md`.
+
+**v1.19 (shipped 2026-04-24):** Phases **82–83** — **AUD-19** (JWT **`user_tokens`** persistence + **`api.jwt_refresh*`** co-fate) + **AUD-20** (**`AUD-04-022`** invalid-code → **`commit_ad_hoc_mfa_audit/5`**). **`83-VERIFICATION.md`** / **`82-VERIFICATION.md`** merge gates. **Phase 84** routing-honesty reconciliation closed **2026-04-25** (`84-VERIFICATION.md`).
**v1.18 (shipped 2026-04-24):** Phase **81** — **AUD-18-01**..**AUD-18-04** — **`audit_jwt_refresh/2`** / **`audit_jwt_refresh_reuse/2`** transactional **`log_multi_safe`** (audit-only txn); **`api_token_audit_atomic_test.exs`**; **44** / **45** / **09** / **`CHANGELOG` [Unreleased]**; **persistence co-fate** → **v1.19**. Verification: **`.planning/phases/81-jwt-refresh-audit-atomicity/81-VERIFICATION.md`**.
@@ -88,11 +141,23 @@ Sigra is a Phoenix 1.8+ authentication platform spanning the v1.0 auth stack, v1
## Next milestone goals
-**v1.19** is **shipped** (**Phases 82–83**, **2026-04-24**). Prefer **CHANGELOG + Hex** for small fixes; **`/gsd-new-milestone`** when **`MAINTAINING.md`** *Resume `/gsd-new-milestone`* criteria match (e.g. loud launch + **SEED-001**, documented adoption gap, **ADR 001** glue).
+**Current ranking source:** [`.planning/MILESTONE-ARC.md`](MILESTONE-ARC.md)
+
+**Immediate next action:** **Define the next milestone after shipped `SESS-CTRL`, starting with a fresh `REQUIREMENTS.md`**
-**Backlog / hygiene:** **Phase 84** owns routing-honesty cleanup after **v1.19**. **`999.1`** and **999.x** are archaeology only; see **`.planning/ROADMAP.md`** and the **`999.1-*`** tombstone files. **`STATE.md`** is session handoff only. **Planning precedence:** **`ROADMAP.md`** + phase **`*-VERIFICATION.md`** / **`*-VALIDATION.md`** over conflicting **`STATE.md`** notes.
+**Recent between-milestones closeout:** **`REL-01 Release Truth Reset`**
-**Later candidates (post–v1.19):** **SEED-001** human matrix before megaphone launch; Phase **45** **T2** clusters (**052–056**, **058**, **063**) only if promoted with owner + trigger; new validation / assurance work uses newly numbered phases rather than **999.x** reuse; **`sigra_lockspire`** per ADR **001** triggers.
+**Ranked follow-ons:**
+- `EMAIL-RAILS` — email reliability, override seams, diagnostics, and provider-agnostic delivery posture
+- `PK-LIFECYCLE` — passkey recovery, last-passkey safety, and cross-device trust
+- `DATA-LIFECYCLE` — auth-data export, audit inclusion, and anonymize/delete lifecycle guidance
+
+**Deferred after `EMAIL-RAILS`:**
+- `sigra_lockspire` glue package per **ADR 001** once a real companion-app trigger fires.
+- Any theme that primarily expands generic admin CRUD, hosted-control-plane behavior, or authz policy rather than the auth control plane itself.
+- Any newly identified validation or assurance work should use newly numbered phases; do not reuse **999.x**.
+
+**Backlog / hygiene:** **`999.1`** / **999.x** remain archaeology only; see **`.planning/ROADMAP.md`** and **`999.1-*`** tombstone files. **`STATE.md`** is session handoff only. **Planning precedence:** **`ROADMAP.md`** + phase **`*-VERIFICATION.md`** / **`*-VALIDATION.md`** over conflicting **`STATE.md`** notes.
Archived v1.2 milestone framing (Admin Dashboard)
@@ -122,7 +187,26 @@ Sigra is a Phoenix 1.8+ authentication platform spanning the v1.0 auth stack, v1
## Requirements
-### Active — _(none — **v1.19** Phases **82–83** shipped **2026-04-24**; next planning follow-up: **Phase 84** / routing honesty only)_
+### Active — Next milestone not yet defined
+
+The active `REQUIREMENTS.md` has been intentionally cleared at the v1.24 boundary. Start the next milestone by selecting a fresh requirement contract rather than carrying v1.24 forward in place.
+
+### Validated — v1.22 Webhooks / outbound event pipeline (shipped 2026-05-06)
+
+_See [`.planning/milestones/v1.22-REQUIREMENTS.md`](milestones/v1.22-REQUIREMENTS.md) for the archived requirement contract and outcomes._
+
+- ✓ **WH-01** — Host app can register outbound webhook subscriptions for Sigra-owned auth and identity events, and Sigra emits signed payloads with stable event IDs, timestamps, and a documented verification contract.
+- ✓ **WH-02** — Each subscription can filter event types, failed deliveries retry automatically with bounded policy, and exhausted deliveries land in a dead-letter state with durable attempt history.
+- ✓ **WH-03** — Generated admin LiveView lets adopters create, enable/disable, rotate, and inspect webhook subscriptions and delivery history without hand-editing Sigra internals.
+
+### Validated — v1.20 GA Launch (shipped 2026-04-28)
+
+- ✓ **LAUNCH-01, LAUNCH-02, LAUNCH-07** — Pre-launch Hex publish and README promotion — **Phase 89**
+- ✓ **AUD-21** — OAuth audit atomicity closure (Phase 45 T2 cluster: 052–056, 058, 063 → atomic) — **Phase 85** (2026-04-25)
+- ✓ **GAUAT-01** — Phase 04 lockout + suspicious-login email visual regression: 8 baselines, evidence under `.planning/uat-evidence/v1.20/email-phase-04/`, 0-human-MUA — **Phase 86** (2026-04-26)
+- ✓ **GAUAT-02** — Phase 08 lifecycle email visual regression: 28 baselines, evidence under `.planning/uat-evidence/v1.20/email-phase-08/`, same residual policy as GAUAT-01 — **Phase 86** (2026-04-26)
+- ✓ **GAUAT-03..09** — OAuth real-credential cycles + MFA backup-code rotation E2E + clean-machine getting-started — **Phases 87–88** (2026-04-26..28)
+- ✓ **LAUNCH-03..06** — CHANGELOG alignment + maintainer monitoring lane (Phase 90 publicity / HN / community soft-launch waived per user direction)
### Validated — v1.19 JWT persistence + audit co-fate & MFA invalid-code audit (shipped in-repo 2026-04-24)
@@ -397,7 +481,7 @@ _SEED-001 and SEED-002 were promoted and **closed in v1.4** (see `.planning/mile
## Constraints
- **Framework:** Phoenix 1.8+ / Ecto 3.x as blessed path. Plug compatibility where DX is not compromised.
-- **Database:** PostgreSQL as primary (citext, JSONB). MySQL/SQLite support via conditional migrations.
+- **Database:** PostgreSQL only (citext, JSONB).
- **Security:** OWASP standards throughout. Argon2id default. All tokens HMAC-protected. Enumeration prevention by default.
- **Dependencies:** Minimal transitive deps. Copy-paste over deps when code is small and stable.
- **LiveView:** Supported but optional. Core works with standard controllers. Login/logout via HTTP POST (not LiveView events).
@@ -471,4 +555,4 @@ This document evolves at phase transitions and milestone boundaries.
-*Last updated: 2026-04-24 — **`v1.19`** Phases **82–83** shipped (**AUD-19** + **AUD-20**); **`REQUIREMENTS.md`**, **`ROADMAP.md`**, **`PROJECT.md`**, **`STATE.md`** aligned.*
+*Last updated: 2026-05-08 — archived `v1.24` session control and reset the active milestone surface for `EMAIL-RAILS` selection.*
diff --git a/.planning/REQUIREMENTS.md b/.planning/REQUIREMENTS.md
deleted file mode 100644
index bd820f9e..00000000
--- a/.planning/REQUIREMENTS.md
+++ /dev/null
@@ -1,46 +0,0 @@
-# Requirements: Sigra — v1.19 JWT persistence + audit co-fate & MFA enrollment failure
-
-**Defined:** 2026-04-24
-**Milestone:** v1.19 — bounded **SEED-002** (**AUD-19** + **AUD-20**)
-
-## v1.19 Requirements
-
-### JWT refresh — persistence + audit co-fate (closes v1.18 “AUD-08 persistence” footnote)
-
-- [x] **AUD-19-01** — On successful JWT refresh, **`Sigra.JWT.RefreshToken.rotate/3`** persistence work (supersede old **`user_tokens`** row + insert new refresh token) and **`api.jwt_refresh`** emission occur in **one** `Repo.transaction` (or equivalent documented single boundary) when `:audit_schema` is set, so the host never observes persisted rotation without a matching audit row, and audit failure rolls back rotation.
-- [x] **AUD-19-02** — On **`:reuse_detected`**, family-wide revocation persistence and **`api.jwt_refresh_reuse`** audit share the same transactional discipline when audit is on (aligned to **AUD-19-01** semantics).
-- [x] **AUD-19-03** — Automated tests prove co-fate: happy path, audit-off, and fault injection (audit insert failure → no partial persistence / consistent `{:error, _}` or documented contract). Prefer extending **`test/sigra/api_token_audit_atomic_test.exs`** and/or focused JWT integration tests.
-- [x] **AUD-19-04** — Planning truth: **`.planning/phases/09-audit-logging/09-VERIFICATION.md`** rows **048–049** footnotes, **`.planning/phases/44-mfa-account-api-atomic-batches/44-AUD-04-INVENTORY.md`**, **`.planning/phases/45-oauth-ops-c1-signoff/45-AUD-04-INVENTORY.md`** (JWT appendix as needed), **`.planning/phases/09-audit-logging/09-03-SUMMARY.md`**, **`CHANGELOG.md` [Unreleased]**; **`.planning/phases/82-jwt-refresh-persistence-audit-cofate/82-VERIFICATION.md`** records merge gate.
-
-### MFA — AUD-04-022 / EX-44-02
-
-- [x] **AUD-20-01** — **`Sigra.MFA.confirm_enrollment/5`** invalid-TOTP (**pre-persistence**) path upgraded from standalone **`log_safe/3`** to **`Repo.transaction/1` + `Multi` + `log_multi_safe`** **or** explicit milestone waiver with updated **EX-44-02** rationale (must be captured in **83** discuss/plan if waived).
-- [x] **AUD-20-02** — **`test/sigra/mfa_audit_atomicity_test.exs`** covers the **022** mechanism + rollback / audit-off parity with prior MFA atomicity phases.
-- [x] **AUD-20-03** — **44** inventory row **022**, **09-VERIFICATION** C-1 **022**, **09-03-SUMMARY**, **`CHANGELOG` [Unreleased]**; **`.planning/phases/83-mfa-confirm-enrollment-022/83-VERIFICATION.md`** merge gate.
-
-## Future requirements
-
-- **ROUTE-84-01** / **02** / **03** — completed in **Phase 84** (**2026-04-25**); see `.planning/phases/84-routing-honesty-reconciliation/84-VERIFICATION.md`.
-- **Phase 45 T2** promotions (**052–056**, **058**, **063**) — only if a later milestone selects them with owner + reopen trigger (**EX-45-***).
-- **SEED-001** human GA matrix — launch lane milestone, not **v1.19**.
-
-## Out of scope
-
-- Re-auditing **Phase 45** merge gate **`mix ci.audit_45`** beyond regression needed for **JWT** path edits.
-- **`sigra_lockspire`** / ADR **001** glue package.
-- **999.x** Nyquist archaeology.
-
-## Traceability
-
-| REQ-ID | Phase |
-|-----------|-------|
-| AUD-19-01 | 82 |
-| AUD-19-02 | 82 |
-| AUD-19-03 | 82 |
-| AUD-19-04 | 82 |
-| AUD-20-01 | 83 |
-| AUD-20-02 | 83 |
-| AUD-20-03 | 83 |
-| ROUTE-84-01 | 84 |
-| ROUTE-84-02 | 84 |
-| ROUTE-84-03 | 84 |
diff --git a/.planning/RETROSPECTIVE.md b/.planning/RETROSPECTIVE.md
index 63d64b04..a397857e 100644
--- a/.planning/RETROSPECTIVE.md
+++ b/.planning/RETROSPECTIVE.md
@@ -1,6 +1,53 @@
# Project Retrospective
-*Living document updated at milestone boundaries. v1.17 section added at milestone close (2026-04-24).*
+*Living document updated at milestone boundaries. v1.21 section added at milestone close (2026-05-06).*
+
+## Milestone: v1.21 — B2B-ready & production-honest
+
+**Shipped:** 2026-05-06
+**Phases:** 6 (91–96) | **Plans (on-disk):** 33 summaries across 26 PLAN.md files | **Timeline:** 2026-04-28 → 2026-05-06 (8 days)
+
+### What was built
+
+- **B2B trust leg** — Phase 91 org-level MFA enforcement (B2B-01) with `Sigra.Plug.RequireOrgMfa` + atomic `organization.mfa_policy_change` audit row; Phase 92 RBAC seams (B2B-02) shipping `Sigra.Authz` behaviour + nullable role on memberships + scope-struct `:role` propagation + role-based-access-control recipe (zero opinionated roles in `lib/sigra/`); Phase 93 M2M service-account tokens (B2B-03) with `client_credentials` grant on existing JWT path + `current_scope.actor_type: :service_account` discriminator + 5 SA-mutation rollback proofs.
+- **Production hardening leg** — Phase 94 Postgres-only declaration (HARD-01) refusing non-Postgres at `mix sigra.install` pre-flight; Phase 95 optional-dep boot validation (HARD-02) via `Sigra.OptionalDeps` SOT + `mix sigra.doctor` + 3 dep-off CI lanes.
+- **OAuth + API polish leg** — Phase 96 OAuth refresh dispatch (HARD-03) for GitHub/Apple/Facebook/Generic via Assent + atomic `oauth.token_refreshed` audit, plus rate-limit headers (API-01) emitting `X-RateLimit-Limit/Remaining/Reset` + `Retry-After` from Hammer state in single-pass plug — 122 passing tests across 4 evidence sections.
+
+### What worked
+
+- **Three legs in parallel** — B2B trust, production hardening, OAuth+API polish each had clean dependency boundaries; only B2B-03 → B2B-02 (`:actor_type` reservation) imposed ordering. Allowed concurrent execution and independent verification.
+- **Milestone audit before close** — Surfaced strict-3-source bookkeeping gaps (Phases 94/96 verifications missing YAML frontmatter; SUMMARYs missing `requirements-completed:`) before they became archaeology debt. Substantive code was already 7/7; the audit/reconcile/close path took ~30 min of mechanical edits.
+- **Phase 95 discipline** — Only v1.21 phase with `nyquist_compliant: true` and full VALIDATION.md. The dep-off CI lanes pattern is reusable for future optional-integration work.
+- **Re-verification cycle on Phase 93** — Initial 5 plans → 5 gaps → gap-closure plans 06–10 → 3 new blockers → critical fixes in commit `bf5a8a8` → final 22/22. Catching the new blockers (Postgrex struct match leak, `nil and x` BadBooleanError) at re-verification rather than after release saved a hot patch.
+
+### What was inefficient
+
+- **`gsd-sdk` binary broken** — Continued from v1.20. Forced manual `milestone.complete` again; SDK CLI returned "Expected gsd-sdk run/auto/init" instead of `query` subcommand. Pre-close audit, milestone archival, and progress queries all done by hand. Same workaround pattern as v1.12–v1.20.
+- **STATE.md drift** — Last STATE.md update was 2026-05-02; it still pointed at "Phase 93 verification" four days after Phase 93 was re-verified clean and Phases 94/95/96 had completed. The 2026-05-06 milestone audit had to reconcile the staleness explicitly. Lesson: STATE.md should be touched at every phase verification, not just at session pause.
+- **Phase 94 "environmental caveat"** — Original 94-VERIFICATION recorded Oban.Worker compile failures as an Elixir 1.19.5 environmental issue and shipped without confirming whether they reproduced after Phase 94's own work landed. The 2026-05-06 audit verified the caveat no longer reproduces — but it sat as ambiguous tech debt for a week. Lesson: env caveats need an explicit "verified still applies on phase close SHA" line before they're allowed in VERIFICATION.
+
+### Patterns established
+
+- **`requirements-completed:` frontmatter on every SUMMARY.md** — promoted from convention to requirement after the milestone audit surfaced 3 phases worth of missing entries.
+- **YAML frontmatter on every VERIFICATION.md** — same. Phase 94/96 had pure-prose VERIFICATIONs that strict 3-source matrix flagged as `unsatisfied` despite having clear passing-test evidence in the body.
+- **Single milestone audit before close, not just at gaps** — even when artifacts look clean, a strict 3-source pass catches frontmatter drift cheaply.
+- **Cross-phase wiring readout in audit** — Skipping the `gsd-integration-checker` subagent and doing a focused readout from existing VERIFICATIONs saved a subagent run; the wires are already documented with code pointers.
+
+### Key lessons
+
+1. **Strict 3-source matrix > prose verification** — A VERIFICATION.md with passing tests but no YAML status is half-finished. Fix this in the discuss-phase template or plan-phase output, not at milestone close.
+2. **Refresh STATE.md at every verification, not at every pause** — staleness compounds and creates false signal at audit time.
+3. **Env caveats need an expiry stamp** — "exists on pristine main" claims should be re-verified at phase close SHA, with the date/SHA recorded, or they become cargo cult.
+4. **Re-verification cycles are good** — finding `Postgrex.Error` struct-match leak and `nil and x` LV bug at re-verification (not after merge) was the correct disposition. Worth keeping the pattern.
+5. **One-shot bookkeeping reconciliation > insert-a-closure-phase** — for milestone-close clean-up work that's purely mechanical (frontmatter cleanup, traceability sync), inline reconciliation in a single commit is faster and clearer than a closure phase chain.
+
+### Cost observations
+
+- Model mix: n/a (not instrumented)
+- Sessions: many (parallel work across 3 legs over 8 days)
+- Notable: 33 plan summaries across 6 phases is the highest plan-density milestone since v1.0 (which had 60 plans across 12 phases). Re-verification on Phase 93 added 5 plans + 2 cycles of fix work; that should be planned for in advance for any milestone that gates on a re-verifiable observable contract.
+
+---
## Milestone: v1.17 — Forced password change audit atomicity
diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md
index dc2529ca..369925f3 100644
--- a/.planning/ROADMAP.md
+++ b/.planning/ROADMAP.md
@@ -2,10 +2,10 @@
## Milestones
-- ✅ **v1.0 Phoenix Auth Library - Initial Release** - Phases 1-10 + 10.1 + 10.1.1 (shipped 2026-04-11). See [v1.0 archive](milestones/v1.0-ROADMAP.md) and [MILESTONES.md](MILESTONES.md).
-- ✅ **v1.1 Foundations** - Phases 11-23 (shipped 2026-04-16). See [v1.1 archive](milestones/v1.1-ROADMAP.md), [v1.1 requirements](milestones/v1.1-REQUIREMENTS.md), and [MILESTONES.md](MILESTONES.md).
-- ✅ **Post-v1.1 Closeout** - Phases 24-26 (completed 2026-04-16).
-- ✅ **v1.2 Admin Dashboard** - Phases 27-31 + gap closure 32-35 (shipped 2026-04-17). See [v1.2 archive](milestones/v1.2-ROADMAP.md), [v1.2 requirements](milestones/v1.2-REQUIREMENTS.md), [v1.2 milestone audit](milestones/v1.2-MILESTONE-AUDIT.md), and [MILESTONES.md](MILESTONES.md).
+- ✅ **v1.0 Phoenix Auth Library — Initial Release** — Phases 1-10 + 10.1 + 10.1.1 (shipped 2026-04-11). See [v1.0 archive](milestones/v1.0-ROADMAP.md), [v1.0 requirements](milestones/v1.0-REQUIREMENTS.md), and [MILESTONES.md](MILESTONES.md).
+- ✅ **v1.1 Foundations** — Phases 11-23 (shipped 2026-04-16). See [v1.1 archive](milestones/v1.1-ROADMAP.md), [v1.1 requirements](milestones/v1.1-REQUIREMENTS.md), and [MILESTONES.md](MILESTONES.md).
+- ✅ **Post-v1.1 Closeout** — Phases 24-26 (completed 2026-04-16).
+- ✅ **v1.2 Admin Dashboard** — Phases 27-31 + gap closure 32-35 (shipped 2026-04-17). See [v1.2 archive](milestones/v1.2-ROADMAP.md), [v1.2 requirements](milestones/v1.2-REQUIREMENTS.md), [v1.2 milestone audit](milestones/v1.2-MILESTONE-AUDIT.md), and [MILESTONES.md](MILESTONES.md).
- ✅ **v1.3 Cleanup & Hardening** — Phases 36-40 (shipped 2026-04-19). See [v1.3 archive](milestones/v1.3-ROADMAP.md), [v1.3 requirements](milestones/v1.3-REQUIREMENTS.md), [v1.3 milestone audit](milestones/v1.3-MILESTONE-AUDIT.md), and [MILESTONES.md](MILESTONES.md).
- ✅ **v1.4 GA readiness & audit trail completeness** — Phases **41–52** (shipped **2026-04-22**). See [v1.4 archive](milestones/v1.4-ROADMAP.md), [v1.4 requirements](milestones/v1.4-REQUIREMENTS.md), [v1.4 milestone audit](milestones/v1.4-MILESTONE-AUDIT.md), and [MILESTONES.md](MILESTONES.md).
- ✅ **v1.5 Public release narrative & community readiness** — Phases **53–56** (shipped **2026-04-22**). See [v1.5 archive](milestones/v1.5-ROADMAP.md), [v1.5 requirements](milestones/v1.5-REQUIREMENTS.md), and [MILESTONES.md](MILESTONES.md).
@@ -15,198 +15,36 @@
- ✅ **v1.9 Audit atomicity (bounded SEED-002)** — Phases **66–67** (shipped **2026-04-23**). See [v1.9 archive](milestones/v1.9-ROADMAP.md), [v1.9 requirements](milestones/v1.9-REQUIREMENTS.md), [v1.9 milestone audit](milestones/v1.9-MILESTONE-AUDIT.md), and [MILESTONES.md](MILESTONES.md).
- ✅ **v1.10 Adopter confidence for solo production** — Phases **68–70** (shipped **2026-04-23**). See [v1.10 archive](milestones/v1.10-ROADMAP.md), [v1.10 requirements](milestones/v1.10-REQUIREMENTS.md), [v1.10 milestone audit](milestones/v1.10-MILESTONE-AUDIT.md), and [MILESTONES.md](MILESTONES.md).
- ✅ **v1.11 Adoption stabilization** — Phases **71–72** (shipped **2026-04-23**). See [v1.11 archive](milestones/v1.11-ROADMAP.md), [v1.11 requirements](milestones/v1.11-REQUIREMENTS.md), and triage [v1.11-TRIAGE.md](v1.11-TRIAGE.md).
-- ✅ **v1.12 Trust, evidence, and adoption polish** — Phases **73–75** (shipped **2026-04-24**). See [v1.12 archive](milestones/v1.12-ROADMAP.md), [v1.12 requirements](milestones/v1.12-REQUIREMENTS.md), and [MILESTONES.md](MILESTONES.md). Bounded **SEED-002** batch + **SEED-001** evidence index + triage-driven doc polish.
-- ✅ **v1.13 Post–v1.12 operational cadence** — Phase **76** (shipped **2026-04-24**). See [v1.13 archive](milestones/v1.13-ROADMAP.md), [v1.13 requirements](milestones/v1.13-REQUIREMENTS.md), and [MILESTONES.md](MILESTONES.md). Planning-only cadence lock-in (**CAD-01**..**CAD-03**).
-- ✅ **v1.14 Bounded audit trust closure** — Phase **77** (shipped **2026-04-24**). See [v1.14 archive](milestones/v1.14-ROADMAP.md), [v1.14 requirements](milestones/v1.14-REQUIREMENTS.md), and [MILESTONES.md](MILESTONES.md). **SEED-002** slice — **AUD-04-033** / **034** (**AUD-13**).
-- ✅ **v1.15 Account + API C-1 planning truth** — Phase **78** (shipped **2026-04-24**). See [v1.15 archive](milestones/v1.15-ROADMAP.md), [v1.15 requirements](milestones/v1.15-REQUIREMENTS.md), and [MILESTONES.md](MILESTONES.md). **AUD-14**..**AUD-14-05**.
+- ✅ **v1.12 Trust, evidence, and adoption polish** — Phases **73–75** (shipped **2026-04-24**). See [v1.12 archive](milestones/v1.12-ROADMAP.md), [v1.12 requirements](milestones/v1.12-REQUIREMENTS.md), and [MILESTONES.md](MILESTONES.md).
+- ✅ **v1.13 Post–v1.12 operational cadence** — Phase **76** (shipped **2026-04-24**). See [v1.13 archive](milestones/v1.13-ROADMAP.md), [v1.13 requirements](milestones/v1.13-REQUIREMENTS.md), and [MILESTONES.md](MILESTONES.md).
+- ✅ **v1.14 Bounded audit trust closure** — Phase **77** (shipped **2026-04-24**). See [v1.14 archive](milestones/v1.14-ROADMAP.md), [v1.14 requirements](milestones/v1.14-REQUIREMENTS.md), and [MILESTONES.md](MILESTONES.md).
+- ✅ **v1.15 Account + API C-1 planning truth** — Phase **78** (shipped **2026-04-24**). See [v1.15 archive](milestones/v1.15-ROADMAP.md), [v1.15 requirements](milestones/v1.15-REQUIREMENTS.md), and [MILESTONES.md](MILESTONES.md).
- ✅ **v1.16 API verify failure audit atomicity** — Phase **79** (shipped **2026-04-24**). See [v1.16 archive](milestones/v1.16-ROADMAP.md), [v1.16 requirements](milestones/v1.16-REQUIREMENTS.md), and [MILESTONES.md](MILESTONES.md).
-- ✅ **v1.17 Forced password change audit atomicity (SEED-002 / AUD-04-043)** — Phase **80** (shipped **2026-04-24**). See [v1.17 requirements](milestones/v1.17-REQUIREMENTS.md), [milestone archive](milestones/v1.17-ROADMAP.md), and [MILESTONES.md](MILESTONES.md).
-- ✅ **v1.18 JWT refresh / reuse audit atomicity (SEED-002 / AUD-04-048..049)** — Phase **81** (shipped **2026-04-24**). [MILESTONES.md](MILESTONES.md); verification [`.planning/phases/81-jwt-refresh-audit-atomicity/81-VERIFICATION.md`](phases/81-jwt-refresh-audit-atomicity/81-VERIFICATION.md).
-- **v1.19 JWT refresh persistence + audit co-fate & MFA enrollment failure** — Phases **82–83** (opened **2026-04-24**). Live [REQUIREMENTS.md](REQUIREMENTS.md); phases **82** then **83** below.
-- ✅ **Post-v1.19 routing honesty follow-up** — Phase **84** (completed **2026-04-25**). Removed stale executable pointers to superseded **`999.1`** and kept future validation work on newly numbered phases only.
-
-## Phases
-
-### v1.19 — shipped **2026-04-24** (Phases **82–83**)
-
-**Coverage:** 7 requirements → 2 phases (**AUD-19** + **AUD-20**). Numbering continues from **v1.18** (last phase **81**).
-
-
-✅ v1.19 MFA **`AUD-04-022`** closure (Phase **83**) — SHIPPED **2026-04-24**
-
-| Phase | Name | Goal | Requirements | Success criteria (observable) |
-|-------|------|------|--------------|----------------------------|
-| **83** | MFA **`AUD-04-022`** closure | Promote invalid pre-DB TOTP on **`confirm_enrollment/5`** to **`commit_ad_hoc_mfa_audit/5`** when `:audit_schema` is set. | AUD-20-01, AUD-20-02, AUD-20-03 | (1) **`lib/sigra/mfa.ex`** uses transactional **`log_multi_safe`** for **`mfa.enroll.failure`**. (2) **`mfa_audit_atomicity_test.exs`** invalid-code matrix. (3) **44** / **09** / **09-03** / **`CHANGELOG`** + **`83-VERIFICATION.md`**. |
-
-**At a glance:** **`Sigra.MFA.confirm_enrollment/5`**; **`test/sigra/mfa_audit_atomicity_test.exs`**; **44-AUD-04-INVENTORY** row **022** + **EX-44-02** appendix; **09-VERIFICATION** C-1 **022** → **T1**. Verification: [`.planning/phases/83-mfa-confirm-enrollment-022/83-VERIFICATION.md`](phases/83-mfa-confirm-enrollment-022/83-VERIFICATION.md).
-
-
-
-
-✅ v1.19 JWT refresh persistence + audit co-fate (Phase 82) — SHIPPED 2026-04-24
-
-| Phase | Name | Goal | Requirements | Success criteria (observable) |
-|-------|------|------|--------------|----------------------------|
-| **82** | JWT refresh persistence + audit co-fate | Single transactional boundary for **`user_tokens`** rotation / reuse revocation and **`api.jwt_refresh`** / **`api.jwt_refresh_reuse`** when audit is on. | AUD-19-01, AUD-19-02, AUD-19-03, AUD-19-04 | (1) **`Sigra.JWT.refresh/3`** does not commit refresh-token DB effects unless audit succeeds when `:audit_schema` set. (2) Reuse path matches same discipline. (3) **`jwt_refresh_audit_cofate_test.exs`** proves rollback / audit-off. (4) **09** / **44** / **45** / **09-03** / **`CHANGELOG`** + **`82-VERIFICATION.md`**. |
-
-**At a glance:** **`Sigra.JWT.refresh/3`** + **`append_api_token_jwt_audit_to_multi`**; **`test/sigra/jwt_refresh_audit_cofate_test.exs`**; **44** / **45** / **09** / **`CHANGELOG` [Unreleased]**; **AUD-08** for guided **`JWT.refresh`**. Verification: [`.planning/phases/82-jwt-refresh-persistence-audit-cofate/82-VERIFICATION.md`](phases/82-jwt-refresh-persistence-audit-cofate/82-VERIFICATION.md) (**merge gate pending** until Postgres test run).
-
-
-
-
-✅ v1.18 JWT refresh / reuse audit atomicity (Phase 81) — SHIPPED 2026-04-24
-
-| Phase | Name | Goal | Requirements | Success criteria (observable) |
-|-------|------|------|--------------|----------------------------|
-| **81** | JWT refresh audit atomicity | Replace hybrid **`log_safe/3`** on **`audit_jwt_refresh/2`** and **`audit_jwt_refresh_reuse/2`** with transactional **`log_multi_safe`** when audit is on; align **44**/**45**/**09**/**CHANGELOG**. | AUD-18-01, AUD-18-02, AUD-18-03, AUD-18-04 | (1) Both helpers use **`Repo.transaction/1`** + audit-only **`Multi` + `log_multi_safe`** when `:audit_schema` set. (2) **`api_token_audit_atomic_test.exs`** covers success, audit-off, and fault injection. (3) **44**/**45** inventories + **09-VERIFICATION** rows **048–049** + **09-03-SUMMARY** + **`CHANGELOG` [Unreleased]** match **`lib/sigra/api_token.ex`**. (4) **`81-VERIFICATION.md`** records merge gate outcome. |
-
-**At a glance:** **81** — **`commit_api_token_jwt_audit/3`**; **`api_token_audit_atomic_test.exs`** JWT rows; **44** / **45** / **09** / **`CHANGELOG` [Unreleased]**; **JWT persistence + audit co-fate** → **v1.19** / **Phase 82**. Verification: [`.planning/phases/81-jwt-refresh-audit-atomicity/81-VERIFICATION.md`](phases/81-jwt-refresh-audit-atomicity/81-VERIFICATION.md).
-
-**Coverage:** 4 requirements → 1 phase.
-
-
-
-
-✅ v1.17 Forced password change audit atomicity (Phase 80) — SHIPPED 2026-04-24
-
-Full phase table, goals, and success criteria are archived in [`milestones/v1.17-ROADMAP.md`](milestones/v1.17-ROADMAP.md).
-
-**At a glance:** **80** — **`Sigra.Account.clear_password_change_requirement/3`** **`Multi` + `log_multi_safe`** for **AUD-04-043**; **`account_audit_atomicity_test.exs`**; **44** / **09** / **09-03-SUMMARY** / **`CHANGELOG` [Unreleased]**; **EX-44-05** closed (**AUD-17**). Verification: [`.planning/phases/80-forced-password-change-audit/80-VERIFICATION.md`](phases/80-forced-password-change-audit/80-VERIFICATION.md).
-
-
-
-
-✅ v1.16 API verify failure audit atomicity (Phase 79) — SHIPPED 2026-04-24
-
-Full phase table, goals, and success criteria are archived in [`milestones/v1.16-ROADMAP.md`](milestones/v1.16-ROADMAP.md).
-
-**At a glance:** **79** — **`Sigra.APIToken.verify/2`** failure **`api.token_verify.failure`** **`Multi` + `log_multi_safe`** (**AUD-04-044..046**); **`api_token_audit_atomic_test.exs`**; **44** / **09** / **09-03-SUMMARY** / **`CHANGELOG` [Unreleased]**; **D-27** preserved. Verification: [`.planning/phases/79-api-token-verify-failure-audit/79-VERIFICATION.md`](phases/79-api-token-verify-failure-audit/79-VERIFICATION.md).
-
-
-
-
-✅ v1.15 Account + API C-1 planning truth (Phase 78) — SHIPPED 2026-04-24
-
-Full phase table, goals, and success criteria are archived in [`milestones/v1.15-ROADMAP.md`](milestones/v1.15-ROADMAP.md).
-
-**At a glance:** **78** — **44** + **09** C-1 planning truth for **AUD-04-035..042**, **047** vs **`lib/sigra/account.ex`** / **`lib/sigra/api_token.ex`**; **`09-03-SUMMARY`** + **`CHANGELOG` [Unreleased]**; **`account_audit_atomicity_test.exs`** **`change_password`**. Verification: [`.planning/phases/78-account-api-c1-planning-truth/78-VERIFICATION.md`](phases/78-account-api-c1-planning-truth/78-VERIFICATION.md).
-
-
-
-
-✅ v1.14 Bounded audit trust closure (Phase 77) — SHIPPED 2026-04-24
-
-Full phase table, goals, and success criteria are archived in [`milestones/v1.14-ROADMAP.md`](milestones/v1.14-ROADMAP.md).
-
-**At a glance:** **77** — **`audit_backup_codes_regenerate/3`** / **`audit_trust_browser/2`** → **`commit_ad_hoc_mfa_audit/5`** (**`Multi` + `log_multi_safe`**); **`mfa_audit_atomicity_test.exs`**; **09** / **44** / **CHANGELOG** truth (**AUD-13-01**..**AUD-13-04**). Verification: [`.planning/phases/77-mfa-adhoc-audit-multi/77-VERIFICATION.md`](phases/77-mfa-adhoc-audit-multi/77-VERIFICATION.md).
-
-
-
-
-✅ v1.13 Post–v1.12 operational cadence (Phase 76) — SHIPPED 2026-04-24
-
-Full phase table, goals, and success criteria are archived in [`milestones/v1.13-ROADMAP.md`](milestones/v1.13-ROADMAP.md).
-
-**At a glance:** **76** — **PROJECT** / **STATE** / **ROADMAP** / **REQUIREMENTS** + **76-VERIFICATION.md** record default Hex patch cadence and trust-signal event lanes (**CAD-01**..**CAD-03**).
-
-
-
-
-✅ v1.12 Trust, evidence, and adoption polish (Phases 73–75) — SHIPPED 2026-04-24
-
-Full phase table, goals, and success criteria are archived in [`milestones/v1.12-ROADMAP.md`](milestones/v1.12-ROADMAP.md).
-
-**At a glance:** **73** bounded **C-1** **`Multi`** + **`log_multi_safe`** + **`mfa_audit_atomicity_test.exs`** (**AUD-11**); **74** **09-03-SUMMARY** + **v1.12-UAT-EVIDENCE** + **`docs/uat-ci-coverage.md`** (**AUD-12**, **UAT-01**, **UAT-02**); **75** **`upgrading-to-v1.12.md`** + trust-bundle surfacing + **`v1.11-TRIAGE.md`** reconciliation (**TRN-01**..**TRN-03**).
-
-
-
-
-✅ v1.11 Adoption stabilization (Phases 71–72) — SHIPPED 2026-04-23
-
-| Phase | Name | Goal | Requirements | Success criteria (observable) |
-|-------|------|------|----------------|----------------------------|
-| **71** | Triage + maintainer pause guidance | Record adoption signals; document when to pause GSD milestones. | STAB-01, STAB-03 | (1) **`.planning/v1.11-TRIAGE.md`** is complete and linked from **`upgrading-to-v1.11.md`**. (2) **`MAINTAINING.md`** contains **Milestone cadence and pause** with pause/resume criteria. |
-| **72** | Upgrade stub + intro cross-links | Planning **v1.11** vs Hex is legible; intro docs surface upgrade pages. | STAB-02, STAB-04 | (1) **`guides/introduction/upgrading-to-v1.11.md`** ships and appears in **`mix.exs`** ExDoc extras after **v1.10** upgrade page. (2) **getting-started** faster path lists **v1.10** and **v1.11** upgrade links; **upgrading-to-v1.10** See also links **v1.11**. |
-
-**Coverage:** 4 requirements → 2 phases. Phase numbering continues from **v1.10** (last phase **70**).
-
-
-
-
-✅ v1.10 Adopter confidence for solo production (Phases 68–70) — SHIPPED 2026-04-23
-
-Full phase table, goals, and success criteria are archived in [`milestones/v1.10-ROADMAP.md`](milestones/v1.10-ROADMAP.md).
-
-**At a glance:** **68** deployment + mail confidence hub (**`068-VERIFICATION.md`**, **ACF-01** / **ACF-04**); **69** intermediate path + **`generator-options`** index (**`069-VERIFICATION.md`**, **ACF-02** / **ACF-03**); **70** **`upgrading-to-v1.10.md`** + non-goal attestation (**`070-VERIFICATION.md`**, **ACF-05** / **ACF-06**).
-
-
-
-
-✅ v1.9 Audit atomicity (Phases 66–67) — SHIPPED 2026-04-23
-
-Full phase table, goals, and success criteria are archived in [`milestones/v1.9-ROADMAP.md`](milestones/v1.9-ROADMAP.md).
-
-**At a glance:** **66** **`confirm_enrollment/5`** **AUD-04-020..021** **`Multi`** + **`mfa_audit_atomicity_test.exs`** (**AUD-09**); **67** **`09-03-SUMMARY.md`** + **D-06** attestation (**AUD-10**).
-
-
-
-
-✅ v1.8 Adopter polish (Phases 63–65) — SHIPPED 2026-04-23
-
-Full phase table, goals, and success criteria are archived in [`milestones/v1.8-ROADMAP.md`](milestones/v1.8-ROADMAP.md).
-
-**At a glance:** **63** **`upgrading-to-v1.8.md`** + ExDoc extras + SemVer framing (**ADOPT-04**); **64** cross-links among getting-started, first-hour, troubleshooting, and upgrade paths (**ADOPT-05**); **65** companion recipe prerequisite / when-not-to-use / See also polish (**INTG-02**).
-
-
-
-
-✅ v1.7 Adoption readiness & audit durability (Phases 60–62) — SHIPPED 2026-04-23
-
-Full phase table, success criteria, and Phase **60** directory note are archived in [`milestones/v1.7-ROADMAP.md`](milestones/v1.7-ROADMAP.md).
-
-**At a glance:** **60** adoption + companion recipe (**ADOPT-01..03**, **INTG-01**); **61** bounded **SEED-002** batch — `verify_backup/4` failure **`Multi`** + **`mfa_audit_atomicity_test.exs`** + **AUD-04-067** (**AUD-01**); **62** **`09-03-SUMMARY.md`** + **AUD-02** closure.
-
-
-
-
-✅ v1.4 GA readiness & audit trail completeness (Phases 41–52) — SHIPPED 2026-04-22
-
-The live phase table, success criteria, and the **44/45 vs 47–49** reader note are archived in [`milestones/v1.4-ROADMAP.md`](milestones/v1.4-ROADMAP.md).
-
-At a glance: **41** backup-code rotation (**GA-01**); **42** GA matrix scaffold; **43–45** audit inventory + Auth / MFA–Account–API / OAuth–ops batches (**AUD-04..AUD-08** implementation); **46** GA matrix gap closure (**GA-02..GA-05**); **47–49** formal `*-VERIFICATION.md` gates + requirements reconciliation; **50** Nyquist policy + **`mix ci.install_golden`** / **`install_golden_contract`**; **51** CI path coupling for installer golden; **52** roadmap and milestone-honesty contract tests.
-
-
-
-
-✅ v1.5 Public release narrative & community readiness (Phases 53–56) — SHIPPED 2026-04-22
-
-Full phase table, goals, and canonical refs are archived in [`milestones/v1.5-ROADMAP.md`](milestones/v1.5-ROADMAP.md).
-
-At a glance: **53** Hex / `mix.exs` metadata (**PUB-01**); **54** `CHANGELOG.md` milestone anchors (**PUB-02**); **55** README + ExDoc GA entry paths (**DOC-01**, **DOC-02**); **56** maintainer **First public launch** checklist in `MAINTAINING.md` (**MAINT-01**).
-
-
-
-
-✅ v1.6 Nyquist closure + OAuth audit depth (Phases 57–59) — SHIPPED 2026-04-23
-
-Full phase table, goals, success criteria, and reader note are archived in [`milestones/v1.6-ROADMAP.md`](milestones/v1.6-ROADMAP.md).
-
-**At a glance:** **57** canonical **41–44** posture matrix + contract test (**NYQ-01**, **NYQ-02**); **58** **`Sigra.OAuthCeremonyAuditTest`** + CI coupling contract (**OA-01**); **59** **OA-02** alignment across **`docs/uat-ci-coverage.md`**, **GA-03** / waiver / evidence **INDEX**, and **`docs/ga-evidence.md`**.
-
-**Reader note:** Phases **41–44** shipped under v1.4; v1.6 makes **Nyquist posture** and **OAuth↔audit machine proof** legible — honest disposition is mandatory.
-
-
-
-### Post-v1.19 follow-up — completed (Phase **84**)
-
-| Phase | Name | Goal | Requirements | Success criteria (observable) |
-|-------|------|------|--------------|----------------------------|
-| **84** | Routing honesty reconciliation | Align **`STATE.md`**, **`ROADMAP.md`**, and related planning surfaces so no active workflow points at superseded **`999.1`**; preserve **`999.1`** as archaeology-only and route any future Nyquist work to newly numbered phases. | ROUTE-84-01, ROUTE-84-02, ROUTE-84-03 | Complete — **`STATE.md`** no longer marks **`999.1`** as next/current/planned, live planning hubs describe **`999.1`** / **`999.2`** as archaeology-only, and **Phase 84** verification artifacts document the routing rule. |
-
-**At a glance:** planning-surface honesty only — no Sigra runtime/library code changes; canonical supersession remains [`.planning/phases/999.1-nyquist-retroactive-validation-pass/999.1-CONTEXT.md`](phases/999.1-nyquist-retroactive-validation-pass/999.1-CONTEXT.md) and Phase **36** evidence. Verification: [`.planning/phases/84-routing-honesty-reconciliation/84-VERIFICATION.md`](phases/84-routing-honesty-reconciliation/84-VERIFICATION.md).
+- ✅ **v1.17 Forced password change audit atomicity** — Phase **80** (shipped **2026-04-24**). See [v1.17 archive](milestones/v1.17-ROADMAP.md), [v1.17 requirements](milestones/v1.17-REQUIREMENTS.md), and [MILESTONES.md](MILESTONES.md).
+- ✅ **v1.18 JWT refresh / reuse audit atomicity** — Phase **81** (shipped **2026-04-24**). See [MILESTONES.md](MILESTONES.md); verification [`.planning/phases/81-jwt-refresh-audit-atomicity/81-VERIFICATION.md`](phases/81-jwt-refresh-audit-atomicity/81-VERIFICATION.md).
+- ✅ **v1.19 JWT refresh persistence + audit co-fate & MFA enrollment failure** — Phases **82–83** (shipped **2026-04-24**). See [MILESTONES.md](MILESTONES.md).
+- ✅ **Post-v1.19 routing honesty follow-up** — Phase **84** (completed **2026-04-25**).
+- ✅ **v1.20 GA Launch — SEED closure + public release** — Phases **85–90** (shipped **2026-04-28**). See [v1.20 archive](milestones/v1.20-ROADMAP.md), [v1.20 requirements](milestones/v1.20-REQUIREMENTS.md), [v1.20 milestone audit](milestones/v1.20-MILESTONE-AUDIT.md), and [MILESTONES.md](MILESTONES.md).
+- ✅ **v1.21 B2B-ready & production-honest** — Phases **91–96** (shipped **2026-05-06**). See [v1.21 archive](milestones/v1.21-ROADMAP.md), [v1.21 requirements](milestones/v1.21-REQUIREMENTS.md), [v1.21 milestone audit](milestones/v1.21-MILESTONE-AUDIT.md), and [MILESTONES.md](MILESTONES.md).
+- ✅ **v1.22 Webhooks / outbound event pipeline** — Phases **97–102** (shipped **2026-05-06**). See [v1.22 archive](milestones/v1.22-ROADMAP.md), [v1.22 requirements](milestones/v1.22-REQUIREMENTS.md), [v1.22 milestone audit](milestones/v1.22-MILESTONE-AUDIT.md), and [MILESTONES.md](MILESTONES.md).
+- ✅ **v1.23 Webhook operator trust & controls** — Phases **103–107** (shipped **2026-05-08**). See [v1.23 archive](milestones/v1.23-ROADMAP.md), [v1.23 requirements](milestones/v1.23-REQUIREMENTS.md), [v1.23 milestone audit](milestones/v1.23-MILESTONE-AUDIT.md), and [MILESTONES.md](MILESTONES.md).
+- ✅ **v1.24 Session Control Plane** — Phases **108–110** (shipped **2026-05-08**). See [v1.24 archive](milestones/v1.24-ROADMAP.md), [v1.24 requirements](milestones/v1.24-REQUIREMENTS.md), [v1.24 milestone audit](milestones/v1.24-MILESTONE-AUDIT.md), and [MILESTONES.md](MILESTONES.md).
## Backlog (parking lot — not in the active roadmap until promoted)
- **999.1** / **999.2** — historical parking-lot labels; shipped in v1.3 — keep directories under `.planning/phases/` as archaeology only. Do not plan new work under **999.x**; use newly numbered phases.
-- **SEED-002** — broad `log_safe/3` → `Ecto.Multi` conversion; trigger when audit-aware refactors are scheduled or compliance demands it.
-- Items not mapped in archived requirements stay here until a future milestone selects them.
+- **`sigra_lockspire` glue package per ADR 001** — still awaiting companion-app trigger; explicitly out of scope for v1.23.
+- **`REL-01` release truth reset** — completed between milestones; reconciled version/release truth across package metadata, changelog framing, and maintainer-facing release docs.
+- **`EMAIL-RAILS` email reliability + override rails** — ranked feature candidate #1; focus on override seams, previews, diagnostics, and provider-agnostic delivery posture.
+- **`PK-LIFECYCLE` passkey lifecycle completion** — ranked feature candidate #2; recovery, last-passkey safety, and cross-device trust matter more than already-shipped passkey CRUD.
+- **`DATA-LIFECYCLE` compliance export + data lifecycle** — ranked feature candidate #3; extend existing export and anonymize seams after more universal adoption blockers are closed.
+- **Built-in opinionated roles** — RBAC stays seams-only per Phase **92**.
+- **MySQL / SQLite adapters** — explicitly removed via Phase **94**; re-evaluate only if an adopter signals concrete demand and is willing to own the adapter.
+- **Phase 999.x archaeology** — pure planning hygiene; tombstone-only.
+- **Items not mapped in archived requirements** — stay here until a future milestone selects them into a new `REQUIREMENTS.md`.
+
+## Arc Notes
+
+- Treat [`.planning/MILESTONE-ARC.md`](MILESTONE-ARC.md) as the ranking source for the next several milestones.
+- Do not treat `SESS-01` or `PK-01` as fresh greenfield gaps: session/device labeling and passkey list/rename/remove are already substantially shipped.
+- Prefer milestones that improve production trust, integration clarity, or DX on rough edges over generic admin expansion or hosted-control-plane imitation.
diff --git a/.planning/STATE.md b/.planning/STATE.md
index 8c820eba..7a656935 100644
--- a/.planning/STATE.md
+++ b/.planning/STATE.md
@@ -1,16 +1,16 @@
---
gsd_state_version: 1.0
-milestone: v1.19
-milestone_name: — shipped **2026-04-24**
-status: ready_to_plan
-last_updated: "2026-04-25T17:09:42.837Z"
-last_activity: 2026-04-25 -- Phase 84 routing honesty reconciliation complete
+milestone: v1.24
+milestone_name: Session Control Plane (shipped)
+status: "v1.24 is archived in planning truth: Phases 108-109 implemented the milestone, Phase 110 authoritatively verified/reconciled it, and the next milestone selection is EMAIL-RAILS unless the user pivots"
+last_updated: "2026-05-08T16:05:00Z"
+last_activity: 2026-05-08
progress:
- total_phases: 72
- completed_phases: 65
- total_plans: 194
- completed_plans: 200
- percent: 90
+ total_phases: 3
+ completed_phases: 3
+ total_plans: 9
+ completed_plans: 9
+ percent: 100
---
# Project State
@@ -23,44 +23,56 @@ See: `.planning/PROJECT.md`
**North star (milestones):** Prefer work that moves **North Star (milestones)** in `.planning/PROJECT.md` — production trust, integration path, DX.
-**Current focus:** **v1.19** shipped; **Phase 84** routing honesty reconciliation is complete. No active **999.x** work is planned.
+**Current focus:** v1.24 milestone archived in planning truth; next milestone selection and fresh requirements are pending.
+**Arc source:** [`.planning/MILESTONE-ARC.md`](MILESTONE-ARC.md) now promotes `EMAIL-RAILS` as the default next milestone candidate.
## Current Position
-Milestone: **v1.19** — **shipped** (**Phases 82–83**, **2026-04-24**)
+Milestone: **v1.24 — Session Control Plane (shipped)**
-Phase: 84 (routing-honesty-reconciliation) — COMPLETE
+Phase: **110 — Session control plane verification closeout**
+Plan: **Archived**
+Status: `SESS-CTRL` is shipped and archived in the active planning surface: Phase 108 shipped preserve-current revoke plus the first session-truth slice, Phase 109 shipped recent security activity plus the remaining truth alignment, and Phase 110 reconciled the active proof surface before archival.
-Plan: 1 of 1
+Last activity: 2026-05-08 — archived the verified v1.24 milestone, copied the audit into `.planning/milestones/`, removed the active `REQUIREMENTS.md`, and promoted `EMAIL-RAILS` to the default next milestone candidate.
-Status: Ready to plan a later newly numbered phase or milestone
+Carried-forward context (non-blocking):
+- DEF-92-02-01 pre-existing audit Multi step-name collision (predates Phase 92)
+- Nyquist coverage thin: 91/92/93 draft VALIDATION.md; 94/96 missing VALIDATION.md (Phase 95 only one with `nyquist_compliant: true`)
-Last activity: 2026-04-25 -- `84-01-SUMMARY.md` and `84-VERIFICATION.md` recorded
+### Quick Tasks Completed
-## Performance Metrics
+| # | Description | Date | Commit | Directory |
+|---|-------------|------|--------|-----------|
+| 260502-lzl | fix PR #37 CI red — 6 mechanical drift fixes (4 commits + 2 documented no-ops; 2357/2358 local pass) | 2026-05-02 | 80ecae7 | [260502-lzl-fix-pr-37-ci-red-6-mechanical-drift-fixe](./quick/260502-lzl-fix-pr-37-ci-red-6-mechanical-drift-fixe/) |
+| 260502-oc7 | fix PR #37 CI groups B + D — Oban-off worker contract + OAuth Assent leak + admin policy arity (4 commits incl. fixture rebless; 2357/2358 local pass) | 2026-05-02 | 022b35b | [260502-oc7-fix-pr-37-ci-groups-b-d-oban-off-worker-](./quick/260502-oc7-fix-pr-37-ci-groups-b-d-oban-off-worker-/) |
+| (manual) | PR #37 CI Group C partial — CLOAK_KEY env added to example_unit_smoke + example_http_smoke + example_playwright_smoke (3 of 6 jobs in Group C; the other 3 already had CLOAK_KEY and need separate diagnosis). Manual fix because gsd-sdk binary is broken (asdf shim → missing dist/cli.js). | 2026-05-02 | 5f32cfc | (no quick dir — manual) |
-_Velocity metrics populate during phase work._
+## Decisions
-## Accumulated Context
-
-**v1.19** — **Phase 83** shipped **AUD-20** — **`Sigra.MFA.confirm_enrollment/5`** invalid TOTP records **`mfa.enroll.failure`** via **`commit_ad_hoc_mfa_audit/5`** (**`Repo.transaction/1`** + **`Multi` + `log_multi_safe`**) when **`:audit_schema`** is set; caller always **`{:error, :invalid_code}`** on crypto failure (**D-83-02**). **Phase 82** shipped **AUD-19** — JWT refresh persistence + audit co-fate (**`Sigra.JWT.refresh/3`**). **Phase 81** standalone **`audit_jwt_refresh*`** helpers unchanged for backward compatibility.
-
-### Pending Todos
-
-- Flip **`82-VERIFICATION.md`** checklist after **`mix test test/sigra/jwt_refresh_audit_cofate_test.exs`** passes locally/CI (merge gate hygiene for **AUD-19** evidence).
-
-### Blockers/Concerns
-
-_None._
+- Completed v1.21 milestone (2026-05-06) with all seven requirements substantively satisfied and reconciled.
+- Closed v1.22 (2026-05-06) with Phases 97-102, including production enqueue repair, operator-state query truth, and generated-host proof evidence under `.planning/uat-evidence/v1.22/generated-host-proof/`.
+- Opened v1.23 (2026-05-07) as the next best leverage point after v1.22: operator-trust follow-ons for the outbound webhook surface, not a release-admin or polish-only detour.
+- Closed the active `SESS-CTRL` milestone truth on 2026-05-08: Phase 108 implemented `SESS-02` and the first `SESS-04/05` slice, Phase 109 implemented `SESS-03` and the remaining `SESS-04/05` alignment, and Phase 110 authoritatively verified/reconciled those outcomes.
+- Archived v1.24 on 2026-05-08: roadmap, requirements, and audit now live under `.planning/milestones/`, and the active planning surface no longer treats session control as an open milestone.
+- Phase 106 is the authoritative replay closeout: Phase 104 implemented `WH-05` and Phase 106 verified and reconciled it.
+- Phase 107 is the authoritative `WH-06` closeout: Phase 105 implemented the egress-policy contract and Phase 107 verified the operator-truth and evidence chain.
+- Phase numbering continues from the shipped webhook milestone; `--reset-phase-numbers` not used.
+- v1.22 remains intentionally outbound-only: Sigra emits auth and identity events to downstream systems; inbound provider webhooks remain out of scope.
+- Phase 102 supersedes the initial `gaps_found` webhook milestone audit through `.planning/phases/102-generated-host-proof-and-planning-reconciliation/102-VERIFICATION.md`.
+- Install-smoke follow-ups from 2026-04-30 were closed on 2026-05-07 as harness maintenance, not milestone requirements.
+- The strategic backlog has been corrected: session/device labeling and passkey CRUD are not fresh missing features; `REL-01` and `SESS-CTRL` are complete, and the next ranking is `EMAIL-RAILS` -> `PK-LIFECYCLE` -> `DATA-LIFECYCLE`.
## Session Continuity
-**Next:** **`/gsd-new-milestone`** or a later newly numbered phase — do not reopen **999.x**
+**Next:** Start the next milestone with a fresh `REQUIREMENTS.md`, defaulting to `EMAIL-RAILS` from [`.planning/MILESTONE-ARC.md`](MILESTONE-ARC.md) unless the user explicitly pivots. Do not reopen `WH-04..06` or `SESS-CTRL` as active implementation work without a new milestone decision.
-**Resume file:** None
+**Artifacts (active):** `.planning/PROJECT.md`, `.planning/ROADMAP.md`, `.planning/STATE.md`, `.planning/MILESTONE-ARC.md`.
-**Artifacts:** `.planning/REQUIREMENTS.md`, `.planning/ROADMAP.md`, **`.planning/phases/84-routing-honesty-reconciliation/84-VERIFICATION.md`**, **`.planning/phases/84-routing-honesty-reconciliation/84-01-SUMMARY.md`**, **`.planning/phases/999.1-nyquist-retroactive-validation-pass/999.1-CONTEXT.md`**, **`.planning/phases/83-mfa-confirm-enrollment-022/83-VERIFICATION.md`**, **`.planning/phases/82-jwt-refresh-persistence-audit-cofate/82-VERIFICATION.md`**
+## Accumulated Context
+
+### Pending Todos
-**Last completed phase:** **84** (routing-honesty-reconciliation) — **2026-04-25**
+- 0 pending todos in `.planning/todos/pending`
-**Planned Phase:** None — future assurance work must use a newly numbered phase, not **999.x**
+**Most recently executed phase:** 110 — Session control plane verification closeout.
diff --git a/.planning/config.json b/.planning/config.json
index c3dd7378..7110baa9 100644
--- a/.planning/config.json
+++ b/.planning/config.json
@@ -46,6 +46,11 @@
"phase_naming": "sequential",
"agent_skills": {},
"resolve_model_ids": "omit",
+ "review": {
+ "models": {
+ "codex": "gpt-5.4"
+ }
+ },
"mode": "yolo",
"granularity": "fine"
}
diff --git a/.planning/debug/resolved/pr37-phantom-sigra-web.md b/.planning/debug/resolved/pr37-phantom-sigra-web.md
new file mode 100644
index 00000000..38c9c036
--- /dev/null
+++ b/.planning/debug/resolved/pr37-phantom-sigra-web.md
@@ -0,0 +1,128 @@
+---
+slug: pr37-phantom-sigra-web
+status: resolved
+trigger: PR #37 CI red — Library tests job creates phantom lib/sigra_web/ in sigra repo via in-process Install.run from a test that lost its raise guard, polluting downstream install_fixture tests
+created: 2026-05-02
+updated: 2026-05-02
+---
+
+# Debug Session: pr37-phantom-sigra-web
+
+## Trigger
+
+
+PR #37 (v1.21 batch including phase 93 ship) ran CI for the first time after the merge of origin/main and turned out 16/23 jobs red.
+
+14 of 16 fails trace to a single root cause: a phantom `lib/sigra_web/` directory that appears in sigra-as-path-dep at compile time on a fresh CI clone, even though `lib/sigra_web/` is not in git, no source defines `SigraWeb.*`, and no `test/support/` references it.
+
+Plan 08 SUMMARY (9a1bbdf, 2026-05-02) explicitly flagged it: "untracked lib/sigra_web/ directory exists in the worktree…pre-existing WIP cruft…prevents running the full test suite with mix test." It was knowingly left behind because targeted tests weren't affected.
+
+CI failure signature:
+ pre-install mix compile failed:
+ lib/sigra_web/components/org_switcher.ex:20 cannot expand Kernel.use/2 while compiling sigra-as-path-dep in test fixture
+
+Goal: find what creates `lib/sigra_web/` in sigra's repo root on a fresh CI clone and remove the source — or if it's a side-effect of running tests, isolate it.
+
+
+## Symptoms
+
+- **Expected behavior:** `MIX_ENV=test mix test` on a fresh clone (no untracked files) compiles and runs the full suite without phantom modules.
+- **Actual behavior:** A `lib/sigra_web/` directory appears in the sigra repo root and is picked up by `sigra-as-path-dep` in test fixtures, breaking compilation of 14/23 CI jobs.
+- **Error message:** `lib/sigra_web/components/org_switcher.ex:20 cannot expand Kernel.use/2 while compiling sigra-as-path-dep in test fixture`
+- **Timeline:** Surfaced after the v1.21 batch / origin/main merge into chore/phase-88-uat-evidence (commit a93f195). Plan 08 SUMMARY (9a1bbdf, 2026-05-02) flagged the cruft as pre-existing.
+- **Reproduction:** Push to `chore/phase-88-uat-evidence` → PR #37 CI; or fresh clone + `MIX_ENV=test mix test test/mix/tasks/sigra.install_test.exs:97` reproduces locally.
+
+## Critical files to inspect first
+
+- `test/support/install_fixture.ex` — esp. `setup_tmp_app/1` and `setup_tmp_app_without_install/1` (lines ~42–168) which `phx.new` and `mix compile` the path-dep
+- `mix.exs` — `elixirc_paths/1` (currently `["lib"]` for non-test, `["lib", "test/support"]` for test)
+- `priv/templates/sigra.install/organizations/components/org_switcher.ex` — the template that gets emitted as `lib/_web/components/org_switcher.ex` after install
+- `priv/templates/sigra.install/admin/components/admin_shell.ex` — same shape, second file in the cascade
+- `lib/mix/tasks/sigra.install.ex` — to understand whether anything resolves the host web module name as `SigraWeb` instead of `Web`
+- `.planning/phases/93-m2m-service-account-tokens-b2b-03/93-08-SUMMARY.md` — has the original "out of scope" admission and may hint at where the cruft came from (look at "Rule 1 - Bug" entries)
+
+## Current Focus
+
+- hypothesis (resolved): The 93-08 fix to `validate_supported_adapter!/1` collapsed two distinct cases into one fallback — "Repo not yet compiled" AND "Repo loaded but has no `__adapter__/0`" both returned `:postgres` instead of raising. This let the `:undetectable_adapter` test in `test/mix/tasks/sigra.install_test.exs:97` slip past the raise guard and run the full installer pipeline against the sigra repo as the current Mix project, generating `lib/sigra_web/...` files there.
+- test: `MIX_ENV=test mix test test/mix/tasks/sigra.install_test.exs:97` produced the phantom directory deterministically before the fix; passes (and leaves the repo clean) after the fix.
+- expecting: After splitting the two cases — fallback only when `Code.ensure_loaded?` is false, raise when the module is loaded but lacks `__adapter__/0` — both the assertion and the directory contamination are gone.
+- next_action: (none — resolved)
+
+## Evidence
+
+- timestamp: 2026-05-02T~14:35Z
+ command: `gh run view 25258893126 --log-failed --job 74062762072` (Library tests)
+ finding: Library tests job error trace shows `module SigraWeb is not loaded ... lib/sigra_web/components/org_switcher.ex:20 ... lib/sigra_web/components/admin_shell.ex:6 ... lib/sigra_web/auth_error_handler.ex:15`. Module name is fully resolved (`SigraWeb.Components.OrgSwitcher`, NOT literal `<%= web_module %>`), so the file was rendered through EEx with `Mix.Phoenix.base() == "Sigra"` — i.e. the installer ran inside the sigra Mix project.
+
+- timestamp: 2026-05-02T~14:38Z
+ command: `gh run view 25258893126 --log-failed --job 74062762053` (Install matrix)
+ finding: install_matrix CI failures are NOT the phantom directory. They are `error: undefined function auth_rate_limit/2 (expected TmpAppWeb.Router to define such a function or for it to be imported, but none are available)` from `mix compile --warnings-as-errors` after `mix sigra.install`. Separate generator drift bug — see "Out of session scope" below.
+
+- timestamp: 2026-05-02T~14:42Z
+ command: read `lib/mix/tasks/sigra.install.ex:156-174`
+ finding: Plan 08 (commit 9a1bbdf, 2026-05-02) changed `validate_supported_adapter!/1` to fall back to `:postgres` "when Repo not loaded". The change collapsed two cases into one: (a) `Code.ensure_loaded?(repo) == false` (Repo not yet compiled), AND (b) Repo loaded but `function_exported?(repo, :__adapter__, 0) == false` (genuinely unknown adapter). The `:undetectable_adapter` test feeds case (b) and now no longer raises.
+
+- timestamp: 2026-05-02T~14:44Z
+ command: `PGUSER=postgres PGPASSWORD=postgres PGHOST=localhost MIX_ENV=test mix test test/mix/tasks/sigra.install_test.exs:97`
+ finding: Test run produces the cascade `* creating lib/sigra/sigra_admin_policy.ex / * creating lib/sigra_web/components/admin_shell.ex / ...` and fails the `assert_raise Mix.Error` because no raise occurs. Confirms reproduction. After test, `git status` shows `?? lib/sigra_web/` and 9 other untracked installer outputs in the sigra repo root.
+
+- timestamp: 2026-05-02T~14:50Z
+ command: edit `lib/mix/tasks/sigra.install.ex` — split `validate_supported_adapter!/1` into a `cond` with three arms: not-loaded → `:postgres` (preserves Plan 08 intent), loaded-no-adapter → `Mix.raise("...Detected an unknown adapter...")`, loaded-with-adapter → only allow `Ecto.Adapters.Postgres`.
+ finding: After re-running `mix test test/mix/tasks/sigra.install_test.exs`: 21 tests, 0 failures. Repo working tree shows only `M lib/mix/tasks/sigra.install.ex` — no phantom `lib/sigra_web/`, no other untracked installer outputs.
+
+- timestamp: 2026-05-02T~14:55Z
+ command: `mix test test/sigra/install/ test/mix/tasks/`
+ finding: 618 tests, 7 failures. Working tree stays clean (no phantom directory). The 7 failures decompose as:
+ 1. `templates_layout_test:70` — manifest count 50 vs 51 (orchestrator-flagged stale assertion, OUT OF SCOPE)
+ 2. `core_post_instructions_test:116` — Oban warning copy drift (orchestrator-flagged stale assertion, OUT OF SCOPE)
+ 3-5. `golden_diff_test:53/66` and `vault_promotion_test:9` — install_fixture tests that fail at `mix compile --warnings-as-errors` inside the generated tmp_app due to the `auth_rate_limit/2` generator-drift bug (NOT in orchestrator's stale-assertion list)
+ 6-7. `generator_passkeys_opt_out_test:33` (×2) — same `auth_rate_limit/2` failure path
+ The phantom-directory bug is fully resolved. The remaining 5 (3-7) failures share a single distinct root cause documented under "Out of session scope" below.
+
+## Eliminated
+
+- The install fixtures (`setup_tmp_app/1`, `setup_tmp_app_without_install/1`) themselves do not contaminate the sigra repo — every `System.cmd` is correctly scoped to a tmp dir under `System.tmp_dir!()`.
+- The `purely_additive_test.exs` walker test uses absolute tmp paths for both files and migrations and changes cwd into tmp before invoking `Runner.run` — also clean.
+- The various `*_test.exs` files that call `Features.X.files(otp_app: :my_app)` etc. are read-only inspections and never write anywhere.
+- The `test/example/` subproject has its own `mix.exs` and never escapes its directory tree.
+- The error message about literal `<%= web_module %>` in earlier writeups was a misread — the CI logs show the module name is fully expanded (`SigraWeb.Components.OrgSwitcher`), so the template DID get EEx-evaluated. The bug is not unrendered-template-leakage; it is the installer running with the wrong host (sigra itself).
+
+## Resolution
+
+**Root cause:** Phase 93 Plan 08 (commit 9a1bbdf, 2026-05-02) changed `validate_supported_adapter!/1` in `lib/mix/tasks/sigra.install.ex` to fall back to `:postgres` whenever the repo module's `__adapter__/0` could not be invoked. This collapsed two structurally distinct cases — "Repo not yet compiled" and "Repo loaded but malformed" — into a single permissive branch. The `:undetectable_adapter` unit test in `test/mix/tasks/sigra.install_test.exs:97` exercises the second case via a stub module with no `__adapter__/0`. With the old raise gone, the test's `Install.run(["Accounts", "User", "users"])` invocation proceeded past validation, computed `Mix.Phoenix.otp_app() == :sigra` and `Mix.Phoenix.base() == "Sigra"`, and ran the full feature walker, generating `lib/sigra_web/components/org_switcher.ex`, `lib/sigra_web/components/admin_shell.ex`, `lib/sigra_web/auth_error_handler.ex`, etc. into the sigra repo root.
+
+Once those files existed, every downstream `setup_tmp_app/1` test failed at the post-`deps.get` `mix compile` step because the tmp_app's path-dep view of sigra now contained Phoenix-shape modules referencing an undefined `SigraWeb` namespace.
+
+**Fix:** `lib/mix/tasks/sigra.install.ex` — split `validate_supported_adapter!/1` into three explicit cases:
+
+```elixir
+defp validate_supported_adapter!(repo_module) do
+ cond do
+ not Code.ensure_loaded?(repo_module) ->
+ :postgres # Plan 08 case: Repo not yet compiled
+ not function_exported?(repo_module, :__adapter__, 0) ->
+ Mix.raise("Sigra supports PostgreSQL only. Detected an unknown adapter. ...")
+ true ->
+ case repo_module.__adapter__() do
+ Ecto.Adapters.Postgres -> :postgres
+ adapter -> Mix.raise("Sigra supports PostgreSQL only. Detected #{inspect(adapter)}. ...")
+ end
+ end
+end
+```
+
+This preserves Plan 08's intent (don't reject a host whose Repo simply hasn't been compiled yet) while restoring the raise guard for the case the test was written to enforce — and which is the actual safety net against running a generator pass with the sigra project as the cwd target.
+
+**Verification:**
+
+- `mix test test/mix/tasks/sigra.install_test.exs` → 21 tests, 0 failures (was 1 failure)
+- `git status` after the run → only `M lib/mix/tasks/sigra.install.ex`, no phantom `lib/sigra_web/`, no other untracked installer artifacts
+- `mix test test/sigra/install/ test/mix/tasks/` → working tree stays clean throughout; only the 7 unrelated failures (4 stale assertions + 3 from a separate generator drift bug, see below) remain
+
+**Out of session scope (reported up to orchestrator):**
+
+5 install-related test failures (`golden_diff_test:53/66`, `vault_promotion_test:9`, `generator_passkeys_opt_out_test:33` ×2) and the 4 install_matrix CI jobs share a separate generator-drift root cause that the orchestrator's briefing did not flag:
+
+`lib/sigra/install/features/core.ex:525` injects `pipe_through [:browser, :redirect_if_user_is_authenticated, :auth_rate_limit]` into the generated host router, but the corresponding `pipeline :auth_rate_limit do plug Sigra.Plug.RateLimit, ... end` block was never added to the same template's `pipelines` section. Commit 3accda8 (2026-05-01, "feat(api): 96-04 wire rate limit and oauth refresh into active seams") added the `pipe_through` reference and the matching pipeline block to `test/example/lib/example_web/router.ex` but missed updating the generator template at `core.ex` line ~494-516. Every fresh `mix sigra.install` therefore emits a router that fails compile with `undefined function auth_rate_limit/2`.
+
+This is not the phantom-directory bug and is structurally distinct: it ships an invalid generator output rather than polluting the library repo. Recommend a separate `/gsd-quick` (or atomic commit alongside the existing 5 mechanical fixes the orchestrator already enumerated) that adds the missing `pipeline :auth_rate_limit do plug Sigra.Plug.RateLimit, error_handler: #{web_module}.AuthErrorHandler end` block to the `# Sigra authentication` injection content in `core.ex`.
diff --git a/.planning/milestones/v1.20-MILESTONE-AUDIT.md b/.planning/milestones/v1.20-MILESTONE-AUDIT.md
new file mode 100644
index 00000000..21f1ea08
--- /dev/null
+++ b/.planning/milestones/v1.20-MILESTONE-AUDIT.md
@@ -0,0 +1,42 @@
+---
+milestone: v1.20
+milestone_name: GA Launch — SEED closure + public release
+audit_status: passed
+audited_at: 2026-04-28
+auditor: milestone close (/gsd-complete-milestone, Gemini CLI)
+retroactive: false
+---
+
+# Milestone audit — v1.20 GA Launch (SEED closure + public release)
+
+## Verdict
+
+**Passed — suitable for archive.** Live `.planning/REQUIREMENTS.md` showed 21/21 requirement checkboxes satisfied/waived with a complete traceability table. Phases 85-89 completed. Phase 90 waived per user instruction.
+
+## Evidence index
+
+| Area | Pointer |
+|------|---------|
+| Requirements (archived) | `milestones/v1.20-REQUIREMENTS.md` |
+| Roadmap / phase intent (archived) | `milestones/v1.20-ROADMAP.md` |
+| AUD-21 | `phases/85-oauth-audit-atomicity-closure-aud-21/85-VERIFICATION.md` |
+| GAUAT Email | `uat-evidence/v1.20/email-phase-04/`, `email-phase-08/` |
+| GAUAT OAuth | `uat-evidence/v1.20/oauth-gen/`, `oauth-google/`, `oauth-link/`, `oauth-email-match/` |
+| GAUAT MFA | `uat-evidence/v1.20/mfa-backup-rotation/` |
+| GAUAT Install | `uat-evidence/v1.20/getting-started-clean-machine/` |
+| Launch | Hex.pm tag `v1.20.0`, `CHANGELOG.md` |
+
+## Requirement cross-check
+
+| ID | Phase | Closure signal |
+|----|-------|----------------|
+| AUD-21 | 85 | `log_safe` boundaries atomic; C-1 PASS; `85-VERIFICATION.md` |
+| GAUAT 01-02 | 86 | `email_visual_regression` CI green; 36 snapshot baselines |
+| GAUAT 03-06 | 87 | Playwright specs against `Testing.OAuthIssuer`; `87-VERIFICATION.md` (CI provenance pending but local verified) |
+| GAUAT 07-09 | 88 | Backup code rotation E2E; Getting started E2E; SEED-001 closed |
+| LAUNCH 01-07 | 89, 90 | Hex published, docs updated, publicity + monitoring waived |
+
+## Explicit non-goals (accepted)
+
+- Publicity launch steps (LAUNCH 03-06) waived to focus on library quality.
+- Lockspire integration deferred.
diff --git a/.planning/milestones/v1.20-REQUIREMENTS.md b/.planning/milestones/v1.20-REQUIREMENTS.md
new file mode 100644
index 00000000..5d2300f9
--- /dev/null
+++ b/.planning/milestones/v1.20-REQUIREMENTS.md
@@ -0,0 +1,98 @@
+# Requirements: Sigra — v1.20 GA Launch (SEED closure + public release)
+
+**Defined:** 2026-04-25
+**Milestone:** v1.20 — GA Launch — SEED closure + public release
+**Selected seeds:** SEED-001 (human GA UAT), SEED-002 (OAuth audit atomicity remainder)
+
+## v1.20 Requirements
+
+### Leg 1 — SEED-002 OAuth audit atomicity closure (AUD-21)
+
+Closes the C-1 caveat that has hung over Phase 9 since v1.0. After this leg, every `log_safe/3` integration site in `lib/sigra/oauth/*` and the OAuth/ops Phase 45 T2 inventory uses atomic `Repo.transaction/1` + `Ecto.Multi` + `Sigra.Audit.log_multi_safe/3` when `:audit_schema` is set, matching the discipline already shipped in `Sigra.MFA`, `Sigra.Account`, and `Sigra.APIToken`.
+
+- [x] **AUD-21-01
+** — Convert OAuth/ops `log_safe/3` clusters at **AUD-04 rows 052–056, 058, 063** (per `.planning/phases/45-oauth-ops-c1-signoff/45-AUD-04-INVENTORY.md`) to atomic `Repo.transaction/1` + `Ecto.Multi` + `log_multi_safe`. On audit-insert failure: callers see `{:error, _}` and business-op rolls back; on `:audit_schema` unset: behavior preserved (telemetry-on-commit only).
+- [x] **AUD-21-02
+** — Audit-aware test coverage at `test/sigra/oauth_audit_atomic_test.exs` (or extension of existing OAuth ceremony tests) proves: happy-path co-fate, audit-off parity, fault-injection rollback (CHECK guard) for each new atomic site.
+- [x] **AUD-21-03
+** — Planning truth refresh: `45-AUD-04-INVENTORY.md` rows 052–056/058/063 marked T1 with phase reference; `09-VERIFICATION.md` C-1 matrix updated; `09-03-SUMMARY.md` post-batch narrative added; `CHANGELOG.md` `[Unreleased]` trace bullet.
+- [x] **AUD-21-04
+** — Phase 9 **C-1 caveat downgraded from PASS-WITH-CAVEATS to PASS** in `09-VERIFICATION.md` frontmatter (`caveats: []` or removal) and `09-03-SUMMARY.md` summary block, with explicit reference to AUD-21 closure. SEED-002 status flipped to `validated` in `.planning/seeds/SEED-002-phase-9-log-safe-atomicity-followup.md` frontmatter.
+- [x] **AUD-21-05
+** — Per-phase merge gate (`*-VERIFICATION.md`) in the implementing phase directory; `mix ci.audit_45` still green; library test suite + 5 CI gates remain green on `main`.
+
+### Leg 2 — SEED-001 GA UAT closure (GAUAT)
+
+Closes the 8 GA-risk UAT items listed in `.planning/seeds/SEED-001-v1.0-ga-human-uat-gate.md`. Each requirement maps to machine-authoritative evidence with recorded outcome and supporting artifacts. No GAUAT row requires a human witness to ship.
+
+- [x] **GAUAT-01** — **Phase 04 lockout + suspicious-login email visual regression (automated)** — Phase 86 ships the `email_visual_regression` CI job rendering both templates (`lockout_notification_email`, `suspicious_login_email`) across {Chromium, WebKit} × {light, dark} via Premailex-inlined HTML + Playwright `toHaveScreenshot`, plus extended ExUnit asserts (computed WCAG contrast, byte budget vs Gmail 102 KB clip, multipart parity, recipient correctness, XSS fuzz, Outlook-Word-engine deny-list, image tripwire) and caniemail.com CSS-feature lint. Eight baselines committed under `test/example/priv/playwright/__snapshots__/email-visual.spec.ts/`. Evidence (README, manifest.json, hero PNGs, contrast-summary.json, byte-budget.csv) under `.planning/uat-evidence/v1.20/email-phase-04/`. Phase-86 CONTEXT.md D-86-09 records the documented residual (legacy Outlook desktop Word engine — EOL Oct 2026; subjective copy tone — handled in PR review; spam-folder placement — adopter deliverability surface). 0 human MUA passes required.
+- [x] **GAUAT-02** — **Phase 08 lifecycle email visual regression (automated)** — Same harness covers the 7 lifecycle templates (`email_change_confirmation_email`, `email_change_notification_email`, `email_changed_email`, `password_changed_email`, `deletion_scheduled_email`, `deletion_cancelled_email`, `deletion_finalized_email`); 28 baselines committed; evidence under `.planning/uat-evidence/v1.20/email-phase-08/`. Same residual policy as GAUAT-01. 0 human MUA passes required.
+- [ ] **GAUAT-03** — **`mix sigra.gen.oauth` fresh-host smoke (automated)** — Extended `scripts/ci/install-smoke.sh` runs on every PR: `mix phx.new` + `sigra.install` + `sigra.gen.oauth --providers google,github` + `mix compile --warnings-as-errors` + `MIX_ENV=test mix test`, emitting `oauth-gen: 12/12 expected artifacts present, mix test green`. Transcript tee'd to `.planning/uat-evidence/v1.20/oauth-gen/transcript.log` (CI artifact + GitHub release asset on `v*` tags). Reshaped from human terminal-transcript capture per Phase 87 D-87-04 (precedent: Phase 86 D-86-08).
+- [ ] **GAUAT-04** — **End-to-end OAuth register/login cycle (automated)** — Playwright spec `oauth-register.spec.ts` drives Sigra's example app against the in-process `Sigra.Testing.OAuthIssuer` (TestServer-backed, RS256 ID tokens, real PKCE — mirrors Assent's own `OIDCTestCase` precedent). Cells: provider button → 302 to /authorize with state nonce → mock auto-consent → callback → user + identity row + session cookie → logout → re-login (same user, no new identity row). Evidence: pass/fail manifest + Playwright trace under `.planning/uat-evidence/v1.20/oauth-google/`. Adopter-side real-credential check ships separately as `mix sigra.oauth.smoketest --provider=google` per Phase 87 D-87-03. Reshaped from human screen-recording capture per Phase 87 D-87-01 (0 human UAT — matches Auth.js / Spring Security / Assent / pow_assent / Devise+omniauth ecosystem convention).
+- [ ] **GAUAT-05** — **Provider linking + last-method unlink prevention (automated)** — Playwright spec `oauth-link.spec.ts` covers four visual states: (1) linked-with-password (unlink enabled), (2) only-oauth-no-password (unlink disabled, tooltip matches verbatim source from `oauth_settings_live.ex:92`), (3) after-set-password (button re-enabled), (4) post-unlink (`user_identities` row absent, password login still works). Evidence: 4-row manifest + one hero PNG of the disabled-tooltip state under `.planning/uat-evidence/v1.20/oauth-link/`. Reshaped from human four-state screenshot capture per Phase 87 D-87-05.
+- [ ] **GAUAT-06** — **Email-match confirmation flash + redirect (automated)** — Playwright spec `oauth-email-match.spec.ts` covers: pre-seeded user with password → mock issuer returns matching email + novel sub → flash text matches verbatim source from `oauth_controller.ex:96` → redirect to login → password login → identity row created → `provider_linked_email` arrival in `/dev/mailbox/json`. Evidence: 4-row manifest + flash-text + DB-probe + mailbox JSON under `.planning/uat-evidence/v1.20/oauth-email-match/`. Reshaped from human screenshot capture per Phase 87 D-87-05.
+- [x] **GAUAT-07
+** — **Backup-code regeneration E2E proof (automated)** — `mfa-backup-rotation.spec.ts` drives the real MFA settings flow in the example app (register/confirm/login → sudo → enroll MFA → capture pre-rotation backup code → regenerate via fresh TOTP) and proves both user-visible and persisted outcomes: new backup codes shown once, old plaintext no longer matches any current code, and `mfa.backup_codes_regenerate` audit persistence. Evidence under `.planning/uat-evidence/v1.20/mfa-backup-rotation/`; CI gate is `.github/workflows/ci.yml / mfa_e2e_playwright`. 0 human UAT required.
+- [x] **GAUAT-08
+** — **Generated-host getting-started proof (automated)** — `scripts/ci/install-smoke.sh` runs the real getting-started path on a disposable Phoenix 1.8 host (`mix phx.new` → Sigra install → compile/migrate → generated-host auth lifecycle test → boot the app and hit the documented routes) and emits machine-readable environment, transcript, and lifecycle evidence under `.planning/uat-evidence/v1.20/getting-started-clean-machine/`. Subjective first-read timing/friction is explicitly non-gating. 0 human UAT required.
+- [x] **GAUAT-09** — **Results filing + seed closure** — `.planning/v1.20-GA-UAT-RESULTS.md` is written with one explicit row per GAUAT-01..08, links to the machine evidence directories under `.planning/uat-evidence/v1.20/`, and a final go/no-go disposition for the launch leg. SEED-001 moves to `validated` when those rows have release-authoritative evidence on the launch SHA/tag; no human-only exception path remains for GAUAT-07
+ or GAUAT-08.
+
+### Leg 3 — Public launch execution (LAUNCH)
+
+Executes the v1.5 `MAINT-01` First Public Launch checklist for the first time. Sequenced *after* legs 1 and 2 close so the launch is defensible. Failures here roll back narrowly (delete announcement, mark Hex release as broken) without invalidating legs 1 and 2.
+
+- [x] **LAUNCH-01
+** — **Hex.pm publish v1.20** — Bump `mix.exs` version to `1.20.0`; tag `v1.20` annotated; `mix hex.publish` (with reviewable diff against the prior published version, if any); verify package shows on hex.pm with correct description, links, optional-deps, and ExDoc. Record release URL. (If this is Sigra's first-ever Hex publish, also covers `mix hex.user auth` setup if not already configured.)
+- [x] **LAUNCH-02
+** — **README "use this in production" promotion** — Update README from "production readiness available" framing to an explicit "Use this in production" section with: link to v1.20 GA evidence, link to Phase 9 C-1 PASS attestation (post-AUD-21), getting-started link, version-pin guidance. ExDoc landing path mirrors the change.
+- [x] **LAUNCH-03** — **Announcement post drafted + published** — *(Waived: User focus is purely on library quality, not publicity. Discussed in Phase 90.)*
+- [x] **LAUNCH-04** — **Hacker News submission** — *(Waived: User focus is purely on library quality. Discussed in Phase 90.)*
+- [x] **LAUNCH-05** — **Elixir community soft-launch** — *(Waived: User focus is purely on library quality. Discussed in Phase 90.)*
+- [x] **LAUNCH-06** — **MAINTAINING.md post-launch monitoring lane** — *(Waived: User focus is purely on library quality, skipped in Phase 90.)*
+- [x] **LAUNCH-07
+** — **CHANGELOG + ExDoc final alignment** — `CHANGELOG.md` v1.20.0 section finalized: covers AUD-21 (audit completeness PASS), GAUAT closure pointer, launch metadata, upgrade notes (none expected — pure additive). ExDoc extras include `upgrading-to-v1.20.md` (or "no upgrade required" stub if changeset is purely additive); `mix docs --warnings-as-errors` clean.
+
+## Future requirements
+
+- **Week-one launch-feedback follow-ups** — sized as a v1.21 patch milestone if signal warrants. Not pre-scoped.
+- **Phase 45 T2 stragglers** beyond 052–056/058/063, if any surface during AUD-21 inventory walk — captured as `EX-45-*` with reopen triggers, deferred to a later milestone.
+- **`sigra_lockspire` glue package per ADR 001** — still awaiting companion-app trigger; explicitly out of scope for v1.20.
+- **30d post-launch retrospective** — formal retrospective on launch-week outcomes, distinct from the LAUNCH-06 monitoring checkpoints. Triggered automatically at the 30d mark.
+
+## Out of scope
+
+- **Reopening 999.x archaeology** — assurance work uses newly numbered phases.
+- **Re-auditing Phase 45 merge gate (`mix ci.audit_45`) beyond regression needed for AUD-21 edits.**
+- **Responding to launch feedback during the v1.20 milestone window** — captured in LAUNCH-06 monitoring lane and routed to a follow-up milestone.
+- **`sigra_lockspire` / ADR 001** — deferred until a real companion-app trigger fires.
+- **Marketing site / standalone landing page** — README + announcement post cover positioning. A dedicated marketing site is a later concern.
+- **Paid promotion / sponsorships** — organic only for first launch.
+
+## Traceability
+
+| REQ-ID | Phase |
+|-----------|-------|
+| AUD-21-01 | 85 |
+| AUD-21-02 | 85 |
+| AUD-21-03 | 85 |
+| AUD-21-04 | 85 |
+| AUD-21-05 | 85 |
+| GAUAT-01 | 86 |
+| GAUAT-02 | 86 |
+| GAUAT-03 | 87 |
+| GAUAT-04 | 87 |
+| GAUAT-05 | 87 |
+| GAUAT-06 | 87 |
+| GAUAT-07 | 88 |
+| GAUAT-08 | 88 |
+| GAUAT-09 | 88 |
+| LAUNCH-01 | 89 |
+| LAUNCH-02 | 89 |
+| LAUNCH-03 | 90 |
+| LAUNCH-04 | 90 |
+| LAUNCH-05 | 90 |
+| LAUNCH-06 | 90 |
+| LAUNCH-07 | 89 |
+
+_(Phase column populated by gsd-roadmapper, 2026-04-25. 21/21 requirements mapped to exactly one phase across Phases 85–90.)_
diff --git a/.planning/milestones/v1.20-ROADMAP.md b/.planning/milestones/v1.20-ROADMAP.md
new file mode 100644
index 00000000..33012e59
--- /dev/null
+++ b/.planning/milestones/v1.20-ROADMAP.md
@@ -0,0 +1,233 @@
+# Roadmap: Sigra
+
+## Milestones
+
+- ✅ **v1.0 Phoenix Auth Library - Initial Release** - Phases 1-10 + 10.1 + 10.1.1 (shipped 2026-04-11). See [v1.0 archive](milestones/v1.0-ROADMAP.md) and [MILESTONES.md](MILESTONES.md).
+- ✅ **v1.1 Foundations** - Phases 11-23 (shipped 2026-04-16). See [v1.1 archive](milestones/v1.1-ROADMAP.md), [v1.1 requirements](milestones/v1.1-REQUIREMENTS.md), and [MILESTONES.md](MILESTONES.md).
+- ✅ **Post-v1.1 Closeout** - Phases 24-26 (completed 2026-04-16).
+- ✅ **v1.2 Admin Dashboard** - Phases 27-31 + gap closure 32-35 (shipped 2026-04-17). See [v1.2 archive](milestones/v1.2-ROADMAP.md), [v1.2 requirements](milestones/v1.2-REQUIREMENTS.md), [v1.2 milestone audit](milestones/v1.2-MILESTONE-AUDIT.md), and [MILESTONES.md](MILESTONES.md).
+- ✅ **v1.3 Cleanup & Hardening** — Phases 36-40 (shipped 2026-04-19). See [v1.3 archive](milestones/v1.3-ROADMAP.md), [v1.3 requirements](milestones/v1.3-REQUIREMENTS.md), [v1.3 milestone audit](milestones/v1.3-MILESTONE-AUDIT.md), and [MILESTONES.md](MILESTONES.md).
+- ✅ **v1.4 GA readiness & audit trail completeness** — Phases **41–52** (shipped **2026-04-22**). See [v1.4 archive](milestones/v1.4-ROADMAP.md), [v1.4 requirements](milestones/v1.4-REQUIREMENTS.md), [v1.4 milestone audit](milestones/v1.4-MILESTONE-AUDIT.md), and [MILESTONES.md](MILESTONES.md).
+- ✅ **v1.5 Public release narrative & community readiness** — Phases **53–56** (shipped **2026-04-22**). See [v1.5 archive](milestones/v1.5-ROADMAP.md), [v1.5 requirements](milestones/v1.5-REQUIREMENTS.md), and [MILESTONES.md](MILESTONES.md).
+- ✅ **v1.6 Nyquist closure + OAuth audit depth** — Phases **57–59** (shipped **2026-04-23**). See [v1.6 archive](milestones/v1.6-ROADMAP.md), [v1.6 requirements](milestones/v1.6-REQUIREMENTS.md), [v1.6 milestone audit](milestones/v1.6-MILESTONE-AUDIT.md), and [MILESTONES.md](MILESTONES.md).
+- ✅ **v1.7 Adoption readiness & audit durability** — Phases **60–62** (shipped **2026-04-23**). See [v1.7 archive](milestones/v1.7-ROADMAP.md), [v1.7 requirements](milestones/v1.7-REQUIREMENTS.md), [v1.7 milestone audit](milestones/v1.7-MILESTONE-AUDIT.md), and [MILESTONES.md](MILESTONES.md).
+- ✅ **v1.8 Adopter polish (diminishing returns)** — Phases **63–65** (shipped **2026-04-23**). See [v1.8 archive](milestones/v1.8-ROADMAP.md), [v1.8 requirements](milestones/v1.8-REQUIREMENTS.md), and [MILESTONES.md](MILESTONES.md).
+- ✅ **v1.9 Audit atomicity (bounded SEED-002)** — Phases **66–67** (shipped **2026-04-23**). See [v1.9 archive](milestones/v1.9-ROADMAP.md), [v1.9 requirements](milestones/v1.9-REQUIREMENTS.md), [v1.9 milestone audit](milestones/v1.9-MILESTONE-AUDIT.md), and [MILESTONES.md](MILESTONES.md).
+- ✅ **v1.10 Adopter confidence for solo production** — Phases **68–70** (shipped **2026-04-23**). See [v1.10 archive](milestones/v1.10-ROADMAP.md), [v1.10 requirements](milestones/v1.10-REQUIREMENTS.md), [v1.10 milestone audit](milestones/v1.10-MILESTONE-AUDIT.md), and [MILESTONES.md](MILESTONES.md).
+- ✅ **v1.11 Adoption stabilization** — Phases **71–72** (shipped **2026-04-23**). See [v1.11 archive](milestones/v1.11-ROADMAP.md), [v1.11 requirements](milestones/v1.11-REQUIREMENTS.md), and triage [v1.11-TRIAGE.md](v1.11-TRIAGE.md).
+- ✅ **v1.12 Trust, evidence, and adoption polish** — Phases **73–75** (shipped **2026-04-24**). See [v1.12 archive](milestones/v1.12-ROADMAP.md), [v1.12 requirements](milestones/v1.12-REQUIREMENTS.md), and [MILESTONES.md](MILESTONES.md). Bounded **SEED-002** batch + **SEED-001** evidence index + triage-driven doc polish.
+- ✅ **v1.13 Post–v1.12 operational cadence** — Phase **76** (shipped **2026-04-24**). See [v1.13 archive](milestones/v1.13-ROADMAP.md), [v1.13 requirements](milestones/v1.13-REQUIREMENTS.md), and [MILESTONES.md](MILESTONES.md). Planning-only cadence lock-in (**CAD-01**..**CAD-03**).
+- ✅ **v1.14 Bounded audit trust closure** — Phase **77** (shipped **2026-04-24**). See [v1.14 archive](milestones/v1.14-ROADMAP.md), [v1.14 requirements](milestones/v1.14-REQUIREMENTS.md), and [MILESTONES.md](MILESTONES.md). **SEED-002** slice — **AUD-04-033** / **034** (**AUD-13**).
+- ✅ **v1.15 Account + API C-1 planning truth** — Phase **78** (shipped **2026-04-24**). See [v1.15 archive](milestones/v1.15-ROADMAP.md), [v1.15 requirements](milestones/v1.15-REQUIREMENTS.md), and [MILESTONES.md](MILESTONES.md). **AUD-14**..**AUD-14-05**.
+- ✅ **v1.16 API verify failure audit atomicity** — Phase **79** (shipped **2026-04-24**). See [v1.16 archive](milestones/v1.16-ROADMAP.md), [v1.16 requirements](milestones/v1.16-REQUIREMENTS.md), and [MILESTONES.md](MILESTONES.md).
+- ✅ **v1.17 Forced password change audit atomicity (SEED-002 / AUD-04-043)** — Phase **80** (shipped **2026-04-24**). See [v1.17 requirements](milestones/v1.17-REQUIREMENTS.md), [milestone archive](milestones/v1.17-ROADMAP.md), and [MILESTONES.md](MILESTONES.md).
+- ✅ **v1.18 JWT refresh / reuse audit atomicity (SEED-002 / AUD-04-048..049)** — Phase **81** (shipped **2026-04-24**). [MILESTONES.md](MILESTONES.md); verification [`.planning/phases/81-jwt-refresh-audit-atomicity/81-VERIFICATION.md`](phases/81-jwt-refresh-audit-atomicity/81-VERIFICATION.md).
+- ✅ **v1.19 JWT refresh persistence + audit co-fate & MFA enrollment failure** — Phases **82–83** (shipped **2026-04-24**). [MILESTONES.md](MILESTONES.md).
+- ✅ **Post-v1.19 routing honesty follow-up** — Phase **84** (completed **2026-04-25**).
+- 🟡 **v1.20 GA Launch — SEED closure + public release** — Phases **85–90** (opened **2026-04-25**). Live [REQUIREMENTS.md](REQUIREMENTS.md); phases below.
+
+## Phases
+
+### v1.20 — active (Phases **85–90**)
+
+**Coverage:** 21 requirements → 6 phases. Numbering continues from **v1.19/post-v1.19** (last phase **84**).
+
+**Dependency shape:** two parallel legs (Leg 1 — AUD-21 OAuth audit closure; Leg 2 — SEED-001 GAUAT closure) feed a sequential launch leg (Leg 3 — pre-launch then launch+monitoring). Legs 1 and 2 are independent and can run in either order. Leg 3 cannot start until **both** legs 1 and 2 close — the launch's defensibility depends on AUD-21 downgrading the Phase 9 C-1 caveat to PASS and GAUAT-09 filing v1.20 GA UAT results with a go-decision.
+
+**Phase summary:**
+
+- [x] **Phase 85: OAuth audit atomicity closure (AUD-21)** — Convert remaining `log_safe/3` OAuth/ops clusters in Phase 45 T2 (AUD-04 rows 052–056, 058, 063) to atomic `Repo.transaction/1` + `Ecto.Multi` + `Sigra.Audit.log_multi_safe/3`; refresh planning truth; downgrade Phase 9 C-1 caveat from PASS-WITH-CAVEATS to PASS.
+- [x] **Phase 86: GAUAT email visual regression harness (Phase 04 + Phase 08 templates)** — Ship automated visual regression harness (Premailex CSS-inline + Playwright `toHaveScreenshot` Chromium+WebKit × light+dark + caniemail CSS lint + extended WCAG/byte/multipart/XSS ExUnit asserts) producing CI-reproducible evidence per template. 0 human MUA passes required for v1.20 launch. (completed 2026-04-26)
+- [ ] **Phase 87: GAUAT OAuth automated end-to-end harness** — Ship `Sigra.Testing.OAuthIssuer` (TestServer-backed in-process OIDC issuer mirroring Assent's `OIDCTestCase`) + 3 Playwright specs (oauth-register / oauth-link / oauth-email-match) covering GAUAT-04/05/06 against Sigra's example app, plus extended `install-smoke.sh` covering GAUAT-03 (`mix phx.new` + `sigra.install` + `sigra.gen.oauth` + `--warnings-as-errors` + `mix test`); ship `mix sigra.oauth.smoketest --provider=google` + `docs/oauth-google-setup.md` for adopter-side real-credential check at install time. 0 human UAT.
+- [x] **Phase 88: GAUAT MFA + getting-started + results filing** — Automated backup-code regeneration E2E proof; generated-host getting-started install/runtime proof; file `.planning/v1.20-GA-UAT-RESULTS.md`; flip SEED-001 status to `validated` when release-SHA evidence is complete. (completed 2026-04-28)
+- [x] **Phase 89: Pre-launch — Hex publish + README promotion + CHANGELOG/ExDoc alignment** — Bump `mix.exs` to 1.20.0; tag `v1.20`; `mix hex.publish`; promote README from "production readiness available" to "use this in production"; finalize CHANGELOG v1.20.0 section + `upgrading-to-v1.20.md` (or no-upgrade-required stub) so `mix docs --warnings-as-errors` is clean. (completed 2026-04-28)
+- [x] ➖ **Phase 90: Launch + monitoring lane** — _Skipped per user request._ (waived 2026-04-28)
+
+## Phase Details
+
+### Phase 85: OAuth audit atomicity closure (AUD-21)
+
+**Goal:** Close the last live SEED-002 audit-atomicity gap so every OAuth/ops integration site that emits a security-relevant audit row co-fates that row with its business-op transaction. After this phase, the Phase 9 C-1 caveat is downgraded from PASS-WITH-CAVEATS to PASS, and SEED-002 is `validated`.
+
+**Depends on:** Nothing (parallel-ready with Phases 86–88).
+
+**Requirements:** AUD-21-01, AUD-21-02, AUD-21-03, AUD-21-04, AUD-21-05.
+
+**Success criteria** (what must be TRUE):
+
+1. A maintainer running `rg "log_safe\\(" lib/sigra/oauth/ lib/sigra/oauth.ex` finds zero hits at the AUD-04 row 052–056/058/063 boundaries that v1.20 targeted; remaining `log_safe` calls are paired with explicit `EX-*` rows in `45-AUD-04-INVENTORY.md` (or have been retired from that inventory).
+2. A maintainer querying `09-VERIFICATION.md` C-1 matrix sees PASS, not PASS-WITH-CAVEATS, with the AUD-21 phase reference embedded; `caveats:` in frontmatter is `[]` (or the field is removed).
+3. Running `mix test test/sigra/oauth_audit_atomic_test.exs` (or the equivalent OAuth-ceremony audit test extension) on Postgres exercises happy-path co-fate, audit-off parity, and CHECK-guarded fault-injection rollback for each newly atomic site, and exits green.
+4. `.planning/seeds/SEED-002-phase-9-log-safe-atomicity-followup.md` frontmatter `status:` reads `validated`; `45-AUD-04-INVENTORY.md` rows 052–056/058/063 carry T1 verdicts with phase 85 reference; `09-03-SUMMARY.md` has a phase-85 / AUD-21 narrative bullet; `CHANGELOG.md` `[Unreleased]` carries the AUD-21 trace bullet.
+5. `mix ci.audit_45` and the library test suite (plus the 5 existing CI gates) remain green on `main` after the phase merges; `85-VERIFICATION.md` records the merge gate outcome.
+
+**Plans:** 4 plans.
+
+Plans:
+- [x] 85-01-PLAN.md — Atomicize impersonation session/audit orchestration and sharpen AUD-04 truth.
+- [x] 85-02-PLAN.md — Close the C-1 narrative, seed status, and verification trail for AUD-21.
+
+### Phase 86: GAUAT email visual regression harness (Phase 04 + Phase 08 templates)
+
+**Goal:** Ship an automated email visual regression harness that produces CI-reproducible evidence for the 9 transactional email templates (2 Phase 04 security + 7 Phase 08 lifecycle) without any human MUA pass. The launch claim is downgraded from "real-mail-client tested" to **"render-tested across Chromium + WebKit engines × light + dark mode, with caniemail-validated CSS for Gmail web / new Outlook web / Apple Mail; legacy Outlook Word-engine desktop documented as out-of-scope (Microsoft EOL Oct 2026)"** — accurate, defensible, and reproducible from any SHA. 0 human UAT for v1.20 launch.
+
+**Depends on:** Nothing (parallel-ready with Phase 85 and with Phases 87–88).
+
+**Requirements:** GAUAT-01, GAUAT-02.
+
+**Success criteria** (what must be TRUE):
+
+1. An external reviewer running `mix test` and `mix ci.email_visual` (or the equivalent CI workflow) on the phase-close SHA produces 36 baseline-matching snapshots (9 templates × 2 engines × 2 themes) byte-equal to the committed baselines under `test/example/priv/playwright/__snapshots__/email-visual.spec.ts/`. Pixel-diff > `maxDiffPixels` fails the build.
+2. An external reviewer opening `.planning/uat-evidence/v1.20/email-phase-04/README.md` and `email-phase-08/README.md` finds: YAML frontmatter (`hex_version`, `git_sha`, `git_tag`, `ci_run_url`, `disposition`); `manifest.json` with one row per (template, engine, theme); `reports/contrast-summary.json` and `byte-budget.csv`; hero PNGs under `snapshots/` named `{template}__{engine}__{theme}__sha-{short-sha}.png` (~1-2 MB total per directory).
+3. The full snapshot bundle (raw `.eml`, all engine PNGs at full res, axe-core JSONs) is uploaded as a GitHub Actions artifact at every CI run AND promoted to the `v1.20.0` GitHub release asset at tag time (release assets do not expire, vs. Actions artifacts capped at 400d — matters for SOC 2 Type II audit windows).
+4. The extended ExUnit suite (`Sigra.A11y.Contrast` module + `Example.EmailAssertions` helper) asserts WCAG 2.2 AA computed contrast (CTA bumped to `#1d4ed8` for 5.17:1 normal-text), byte budget < 100 KB vs Gmail clip, multipart parity (text mirrors HTML URLs), recipient correctness, XSS fuzz on user-controlled fields, Outlook Word-engine deny-list (no `
+
+
+ Hello
+
+