Skip to content

fix(inbound): guard re-approve on already-active grant against unique-index 500 - #2369

Open
detail-app[bot] wants to merge 1 commit into
masterfrom
detail/bug-fix/fix-inbound-guard-re-approve-on-already-active-gra-5a85df
Open

fix(inbound): guard re-approve on already-active grant against unique-index 500#2369
detail-app[bot] wants to merge 1 commit into
masterfrom
detail/bug-fix/fix-inbound-guard-re-approve-on-already-active-gra-5a85df

Conversation

@detail-app

@detail-app detail-app Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Detail bug report: View on Detail

Summary

Re-approving an already-active inbound grant (after an approve→revoke→re-approve cycle) raised an unhandled asyncpg.UniqueViolationError on the partial unique index inbound_grants_live_uniq, surfacing as HTTP 500 instead of the documented ConflictError (HTTP 409). This broke the idempotent re-approval contract for retried / scripted / double-submitted approve calls on an already-admitted chat.

Substrate state changes

None — code-only change. No migration, env-var, or external-service contract change; the partial unique index is unchanged (only the query was missing a guard).

Bug & Fix

Bug: approve_inbound_grant's inserted CTE revived a revoked grant by inserting a fresh active row, gated only on "no pending row" + "a revoked row exists". After approve→revoke→re-approve, the ledger holds both an active row (current) and a revoked row (history). A further approve took the inserted INSERT path and collided with inbound_grants_live_uniq (WHERE status <> 'revoked'), aborting the whole statement before the existing if row is None: raise ConflictError(...) fallthrough could run — so the API returned a bare "Internal Server Error" 500, and the constraint name went only to server logs.

Fix: Added AND NOT EXISTS (SELECT 1 FROM inbound_grants WHERE ... AND status = 'active') to the inserted CTE so it only inserts when no active row already exists. For an already-active chat both the promoted (pending→active) and inserted (revoked→new active) CTEs are empty, the outer granted CTE yields no row, and the existing row is NoneConflictError branch returns HTTP 409. This is atomic/idempotent and prevents the partial-index collision rather than catching it, matching the query's own design and the function's existing conflict message. A try/except asyncpg.UniqueViolationError alternative (used elsewhere in the codebase) was not chosen because it catches after the statement aborts; the guard makes the already-active case a first-class no-op within the single statement.

Test plan

  • Regression tests (tests/integration/test_inbound_grants.py): two service-layer tests — the exact approve→revoke→re-approve→re-approve sequence (asserts ConflictError and ledger ["active","revoked"]), and the previously-unasserted already-active-never-revoked 409 path. Verified to fail with the bug's exact UniqueViolationError when the fix is reverted.
  • End-to-end HTTP contract (tests/integration/test_reapprove_http_contract.py): mounts the real connections.router + real install_exception_handlers over a real migrated DB pool (driven via httpx.ASGITransport) to assert the 4th approve returns HTTP 409 with the aios {"error":{"type":"conflict",...}} envelope, not a 500; plus a concurrent-approve probe asserting all concurrent results are ConflictError and exactly one active row remains.
  • No regressions: the existing approve→revoke→re-approve history test, the inbound admission ledger suite, reparent, archived-connection, and pending-GC tests all pass; the tests/unit/test_inbound_admission.py decision-logic suite passes.
  • Checks: mypy src tests, ruff check, and ruff format --check all pass; openapi.json and the generated SDK show no drift (API surface unchanged).

Risk / rollback

Code-only and low-risk: the guard only suppresses an INSERT that would have crashed, steering it to the already-existing ConflictError branch. Roll back by reverting the commit; no migration to undo and no state to repair (the bug never produced corrupt rows — the INSERT was rejected by the unique index).


Automatic Fixes PRs can be configured here.

…-index 500

`approve_inbound_grant`'s `inserted` CTE revived a revoked grant by inserting a
fresh `active` row, but gated only on "no pending row" + "a revoked row exists".
After an approve→revoke→re-approve cycle, the ledger holds both an `active` row
(current) and a `revoked` row (history) for the same (connection_id, chat_id). A
further approve took the `inserted` INSERT path and collided with the partial
unique index `inbound_grants_live_uniq` (`WHERE status <> 'revoked'`), raising
an unhandled `asyncpg.UniqueViolationError` that surfaced as HTTP 500 instead of
the documented `ConflictError` (HTTP 409). The crash aborted the whole statement
before the existing `if row is None: raise ConflictError(...)` fallthrough could
run, so retried/scripted/double-submitted re-approvals of an already-admitted
chat crashed with a bare "Internal Server Error" rather than a clean 409.

Add a `AND NOT EXISTS (... status = 'active')` guard to the `inserted` CTE so it
only inserts when no active row already exists. With both the `promoted`
(pending→active) and `inserted` (revoked→new active) CTEs empty for an
already-active chat, the outer `granted` CTE yields no row and the existing
`row is None` → `ConflictError` branch returns HTTP 409 — atomic and idempotent,
preventing the partial-index collision rather than catching it. This matches
the query's own design and the function's existing conflict message; the
codebase-conventional `try/except asyncpg.UniqueViolationError` alternative was
not used because it catches the violation after the statement aborts, and the
CTE guard makes the already-active case a first-class no-op within the single
statement.

Tests:
- `tests/integration/test_inbound_grants.py`: two service-layer regression
  tests — re-approve on already-active with revoked history (the exact bug
  sequence) and re-approve on already-active with no revoked history (the
  previously-unasserted non-promotable 409 path).
- `tests/integration/test_reapprove_http_contract.py`: end-to-end HTTP
  contract + concurrency tests mounting the real `connections.router` + real
  `install_exception_handlers` over a real DB pool (driven via
  `httpx.ASGITransport`) to assert the 409 aios error envelope, not a 500; plus
  a concurrent-approve probe asserting no 500 and exactly one active row.

Co-Authored-By: Detail <dev@aios>
@detail-app
detail-app Bot requested a review from eumemic September 4, 2026 23:49
@eumemic-bot

eumemic-bot Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Code review

Verdict: pass

Reviewed head 642405b305e5d2be91ba7d65cb9e7ad5336d1460. The added active-grant predicate correctly prevents the revoked-history insertion path from colliding with the live-grant uniqueness constraint, while preserving the existing conflict response when no grant is produced. Regression coverage exercises service, HTTP-envelope, ledger-state, and concurrent re-approval behavior.

No blocking findings. Ruff and mypy passed locally for the changed files; GitHub lint, unit, and integration checks completed successfully.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant