From e6d7e7a37716a035fbab8b641bdcb997007e5089 Mon Sep 17 00:00:00 2001 From: evg-g <229170690+evg-g@users.noreply.github.com> Date: Sun, 4 Oct 2026 08:45:32 +0300 Subject: [PATCH 1/7] docs(db): ADRs for AURORA-3 ADR 0017 records the fix for success responses on failed commits: commit before the response (dependency scope "function"), a catch-all 500 problem+json handler, and request-scoped transaction hooks so webhooks and booking idempotency act only after the commit. CONTEXT.md gains the terms. Co-Authored-By: Claude Opus 5.5 --- CONTEXT.md | 6 +++ ...t-before-response-and-transaction-hooks.md | 53 +++++++++++++++++++ 2 files changed, 59 insertions(+) create mode 100644 docs/adr/0017-commit-before-response-and-transaction-hooks.md diff --git a/CONTEXT.md b/CONTEXT.md index 3c33eb6..e082a4d 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -18,3 +18,9 @@ A short glossary of the terms tickets and ADRs use. Add a term when a ticket int (ADR 0016). - **Audit writer** — the `AuditWriter` Protocol the audit service records through; implemented by `AuditLogRepository` (ADR 0016). +- **Commit failure** — the database refuses the request's COMMIT (a deferred constraint, a + serialization failure, a lost connection). The client gets an error, and nothing from the request + is saved (ADR 0017). +- **Transaction hooks** — the request-scoped `on_commit` / `on_rollback` callback lists that + `get_session` runs after the commit or the rollback; webhook publishing and booking idempotency + use them (ADR 0017). diff --git a/docs/adr/0017-commit-before-response-and-transaction-hooks.md b/docs/adr/0017-commit-before-response-and-transaction-hooks.md new file mode 100644 index 0000000..1ebc211 --- /dev/null +++ b/docs/adr/0017-commit-before-response-and-transaction-hooks.md @@ -0,0 +1,53 @@ +# ADR 0017 — Commit before the response, and transaction hooks + +Status: accepted (AURORA-3) + +## Context + +`get_session` (`db.py`) commits in the exit of a yield dependency. In FastAPI 0.141.1 the default +dependency scope runs that exit after the response is sent, so a request whose COMMIT fails still +returns its success response while the database keeps the old state. This affects every write +endpoint that uses `SessionDep`. It was found while proving AURORA-2. + +Two side effects make the same mistake one step later, even once the commit moves: + +- Webhook events are published to Redis before the commit (`KNOWN_GAPS.md`, ADR 0010), so a failed + commit can still notify receivers about a change that was not saved. +- Booking with an `Idempotency-Key` stores the 201 response before the commit, so after a failed + commit a retry with the same key replays a success for a booking that does not exist. + +## Decision + +- **Commit before the response.** `SessionDep` is `Depends(get_session, scope="function")`. The + commit runs after the handler and before the response is sent, so a commit error goes through the + normal exception handlers. +- **A catch-all error handler.** An unhandled exception returns 500 as `application/problem+json` + (RFC 9457, ADR 0006). `IntegrityError` keeps its 409 mapping and `StaleDataError` its 412. +- **Transaction hooks.** A request-scoped `TransactionHooks` object holds two callback lists, + `on_commit` and `on_rollback`, and is provided as a dependency. `get_session` runs `on_commit` + after `commit()` succeeds and `on_rollback` after a rollback. Each hook runs on its own: a failing + `on_commit` hook is logged and does not fail the request, because the change is already saved + and the response must match the database. +- **Webhooks after commit.** `_emit` registers the publish as an `on_commit` hook instead of + publishing during the request. +- **Booking idempotency after commit.** `IdempotencyService.complete()` is registered as an + `on_commit` hook and `release()` as an `on_rollback` hook, so a failed commit frees the key and a + retry with the same key is processed as a new request. +- **One way to force a failed commit in tests.** A shared integration fixture installs a test-only + `DEFERRABLE INITIALLY DEFERRED` constraint trigger that raises at COMMIT for one chosen row, and + always drops it. + +## Consequences + +- No write endpoint can return success for a change the database did not save. +- A crash, or a Redis failure, between the commit and an `on_commit` hook loses that hook's work: a + webhook event is not published (logged), or a booking's idempotency record is not stored. In the + second case the key's pending marker stays, so a retry with the same key gets 409 "still being + processed" until the key's TTL (`idempotency_ttl_seconds`, 24 h) expires; the booking itself is + saved, and a retry with a new key for the same slot is refused by the double-booking constraint. + Exactly-once emission needs a transactional outbox; that is deferred to a later ticket (AURORA-4). +- The `KNOWN_GAPS.md` entry "Webhook emission is not transactional with the DB commit" narrows to + that crash/Redis window, and the "commit after response" behaviour is gone. +- The behaviour depends on FastAPI's dependency `scope`. The commit-failure integration tests guard + it against a framework change. +- No migration and no OpenAPI contract change. From 52380ec296e26e2a6ae13701064b1b611d74bd9e Mon Sep 17 00:00:00 2001 From: evg-g <229170690+evg-g@users.noreply.github.com> Date: Sun, 4 Oct 2026 10:17:54 +0300 Subject: [PATCH 2/7] fix(db): AURORA-3 AC3.data AC1.api commit before the response Co-Authored-By: Claude Opus 5.5 --- src/appointments_api/api/deps.py | 5 +- src/appointments_api/api/errors.py | 7 + tests/integration/conftest.py | 79 ++++++- tests/integration/test_audit_log_cancel.py | 69 +++--- tests/integration/test_commit_failure.py | 249 +++++++++++++++++++++ 5 files changed, 363 insertions(+), 46 deletions(-) create mode 100644 tests/integration/test_commit_failure.py diff --git a/src/appointments_api/api/deps.py b/src/appointments_api/api/deps.py index 5868aa6..37eb476 100644 --- a/src/appointments_api/api/deps.py +++ b/src/appointments_api/api/deps.py @@ -44,7 +44,10 @@ from appointments_api.services.webhooks.dispatcher import WebhookDispatcher from appointments_api.telemetry_wiring import build_ingestion_service -SessionDep = Annotated[AsyncSession, Depends(get_session)] +# scope="function": the commit in get_session's exit runs after the handler and BEFORE the response +# is sent, so a failed COMMIT goes through the exception handlers instead of following a success +# response (ADR 0017). +SessionDep = Annotated[AsyncSession, Depends(get_session, scope="function")] RedisDep = Annotated[Redis, Depends(get_redis)] SettingsDep = Annotated[Settings, Depends(get_settings)] diff --git a/src/appointments_api/api/errors.py b/src/appointments_api/api/errors.py index 8689bcb..b61e646 100644 --- a/src/appointments_api/api/errors.py +++ b/src/appointments_api/api/errors.py @@ -231,3 +231,10 @@ async def _integrity(request: Request, exc: IntegrityError) -> JSONResponse: else: problem = ConflictError(detail="The change conflicts with existing data.") return _json(problem.to_problem(request.url.path)) + + @app.exception_handler(Exception) + async def _unhandled(request: Request, exc: Exception) -> JSONResponse: + # Catch-all (ADR 0017): any unhandled error, including a failed COMMIT, is a 500 problem + # document. Starlette still re-raises it after this response, so it is logged as usual. + problem = APIError(detail="An unexpected error occurred.") + return _json(problem.to_problem(request.url.path)) diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 9a70ab9..e6307ea 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -12,6 +12,7 @@ import uuid from collections.abc import AsyncIterator, Iterator from datetime import datetime +from typing import Literal import httpx import pytest @@ -19,7 +20,7 @@ from alembic.config import Config from fastapi import FastAPI from sqlalchemy import text -from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker +from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker from testcontainers.postgres import PostgresContainer from testcontainers.redis import RedisContainer @@ -102,6 +103,82 @@ async def client(app: FastAPI) -> AsyncIterator[httpx.AsyncClient]: yield c +@pytest.fixture +async def raw_client(app: FastAPI) -> AsyncIterator[httpx.AsyncClient]: + """A client that returns the app's 500 response instead of re-raising the app's exception.""" + transport = httpx.ASGITransport(app=app, raise_app_exceptions=False) + async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as c: + yield c + + +# ---- forcing a failed COMMIT (ADR 0017) ---- + +CommitFailureMode = Literal["error", "integrity"] +TriggerEvent = Literal["INSERT", "UPDATE", "DELETE"] + +# SQLSTATE raised at COMMIT. P0001 (raise_exception) surfaces as a general database error; +# 23514 (check_violation) is an integrity-constraint class, so SQLAlchemy raises IntegrityError. +_SQLSTATE: dict[CommitFailureMode, str] = {"error": "P0001", "integrity": "23514"} + + +class CommitFailure: + """Installs test-only deferred constraint triggers that make a COMMIT fail for one chosen row. + + A ``DEFERRABLE INITIALLY DEFERRED`` constraint trigger runs at COMMIT time, not at the + statement, so the request handler runs to the end (every write flushed) and only the COMMIT + is refused. ``when`` is a SQL boolean over ``NEW`` (INSERT/UPDATE) or ``OLD`` (DELETE) that + picks the one row; callers inline only validated values (UUIDs, fixed literals). + """ + + def __init__(self, engine: AsyncEngine) -> None: + self._engine = engine + self._installed: list[tuple[str, str]] = [] + + async def arm( + self, + *, + table: str, + event: TriggerEvent, + when: str, + mode: CommitFailureMode = "error", + ) -> None: + name = f"test_fail_commit_{len(self._installed)}" + row = "OLD" if event == "DELETE" else "NEW" + # Register before creating, so teardown drops a half-installed trigger as well. + self._installed.append((name, table)) + async with self._engine.begin() as conn: + await conn.execute( + text( + f"CREATE FUNCTION {name}() RETURNS trigger LANGUAGE plpgsql AS $$ " + f"BEGIN IF {when} THEN RAISE EXCEPTION 'test: commit refused ({name})' " + f"USING ERRCODE = '{_SQLSTATE[mode]}'; END IF; RETURN {row}; END $$" + ) + ) + await conn.execute( + text( + f"CREATE CONSTRAINT TRIGGER {name} AFTER {event} ON {table} " + f"DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION {name}()" + ) + ) + + async def disarm(self) -> None: + async with self._engine.begin() as conn: + for name, table in self._installed: + await conn.execute(text(f"DROP TRIGGER IF EXISTS {name} ON {table}")) + await conn.execute(text(f"DROP FUNCTION IF EXISTS {name}()")) + self._installed.clear() + + +@pytest.fixture +async def fail_commit(app: FastAPI) -> AsyncIterator[CommitFailure]: + """Arm COMMIT failures for chosen rows; every trigger is dropped when the test ends.""" + failure = CommitFailure(app.state.engine) + try: + yield failure + finally: + await failure.disarm() + + # ---- seeding helpers ---- diff --git a/tests/integration/test_audit_log_cancel.py b/tests/integration/test_audit_log_cancel.py index 0352ace..7f6b1b2 100644 --- a/tests/integration/test_audit_log_cancel.py +++ b/tests/integration/test_audit_log_cancel.py @@ -16,7 +16,7 @@ import httpx import pytest from fastapi import FastAPI -from sqlalchemy import select, text +from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker from appointments_api.api.deps import SessionDep, get_audit_log_repository, get_clock @@ -24,6 +24,7 @@ from appointments_api.repositories.audit_log import AuditLogRepository from appointments_api.repositories.redis_webhooks import RedisEventQueue from tests.fakes.clock import FixedClock +from tests.integration.conftest import CommitFailure from tests.integration.helpers import auth_header ALL_WEEK = [(wd, "06:00", "22:00") for wd in range(7)] @@ -338,51 +339,31 @@ def _failing_repo(session: SessionDep) -> AuditLogRepository: @pytest.mark.agent_trusted async def test_ac1_data_failed_cancel_commit_leaves_no_audit_row( - client: httpx.AsyncClient, app: FastAPI, world: dict[str, Any] + client: httpx.AsyncClient, + raw_client: httpx.AsyncClient, + app: FastAPI, + world: dict[str, Any], + fail_commit: CommitFailure, ) -> None: # The real audit wiring (no override): if the request's COMMIT fails after the audit write, the # audit row must roll back with the cancel: the cancel is not durable, and neither is its row. - # A test-only deferred constraint trigger raises at COMMIT time for this one appointment, so - # only a row written in the same transaction as the cancel vanishes with it. + # A test-only deferred constraint trigger (the fail_commit fixture, ADR 0017) raises at COMMIT + # time for this one appointment, so only a row written in the same transaction vanishes with it. appt_id, etag = await _book(client, world, _outside_cutoff(5)) target = uuid.UUID(appt_id) # validated UUID, safe to inline in the trigger body - engine = app.state.engine - async with engine.begin() as conn: - await conn.execute( - text( - "CREATE FUNCTION ac1_fail_cancel_commit() RETURNS trigger LANGUAGE plpgsql AS $$ " - f"BEGIN IF NEW.id = '{target}'::uuid AND NEW.status::text = 'CANCELLED' THEN " - "RAISE EXCEPTION 'ac1: commit of cancel refused'; END IF; RETURN NEW; END $$" - ) - ) - await conn.execute( - text( - "CREATE CONSTRAINT TRIGGER ac1_fail_cancel_commit AFTER UPDATE ON appointments " - "DEFERRABLE INITIALLY DEFERRED FOR EACH ROW " - "EXECUTE FUNCTION ac1_fail_cancel_commit()" - ) - ) - try: - transport = httpx.ASGITransport(app=app, raise_app_exceptions=False) - async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as raw: - resp = await _cancel(raw, appt_id, world["patient_token"], etag) - # get_session commits after the response is sent (FastAPI yield-dependency exit), so the - # client may still see 200 for a cancel whose COMMIT failed. Either way the handler must - # have run to the end (cancel flushed, audit row written) before the commit was refused. - assert resp.status_code == 200 or resp.status_code >= 500, (resp.status_code, resp.text) - if resp.status_code == 200: - assert resp.json()["status"] == "CANCELLED", resp.json() - - maker: async_sessionmaker[AsyncSession] = app.state.sessionmaker - async with maker() as session: - appointment = await session.get(Appointment, target) - assert appointment is not None - assert appointment.status.value == "REQUESTED" - assert appointment.cancellation_reason is None - assert list(await _audit_rows(app, appt_id)) == [] - finally: - async with engine.begin() as conn: - await conn.execute( - text("DROP TRIGGER IF EXISTS ac1_fail_cancel_commit ON appointments") - ) - await conn.execute(text("DROP FUNCTION IF EXISTS ac1_fail_cancel_commit()")) + await fail_commit.arm( + table="appointments", + event="UPDATE", + when=f"NEW.id = '{target}'::uuid AND NEW.status::text = 'CANCELLED'", + ) + resp = await _cancel(raw_client, appt_id, world["patient_token"], etag) + # The commit runs before the response (ADR 0017), so the refused COMMIT is a 500. + assert resp.status_code == 500, (resp.status_code, resp.text) + + maker: async_sessionmaker[AsyncSession] = app.state.sessionmaker + async with maker() as session: + appointment = await session.get(Appointment, target) + assert appointment is not None + assert appointment.status.value == "REQUESTED" + assert appointment.cancellation_reason is None + assert list(await _audit_rows(app, appt_id)) == [] diff --git a/tests/integration/test_commit_failure.py b/tests/integration/test_commit_failure.py new file mode 100644 index 0000000..efd11c2 --- /dev/null +++ b/tests/integration/test_commit_failure.py @@ -0,0 +1,249 @@ +"""A write request whose COMMIT fails (ADR 0017), end to end against real Postgres. + +The commit runs before the response is sent, so a refused COMMIT reaches the client as a problem +document (500, or 409 for a constraint violation) and never as a success. Nothing from that +request is saved. Cancel, book, create clinic and delete webhook subscription represent the +write endpoints that use ``SessionDep``. +""" + +from __future__ import annotations + +import uuid +from datetime import UTC, datetime +from typing import Any + +import httpx +import pytest +from fastapi import FastAPI +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker + +from appointments_api.api.deps import get_clock +from appointments_api.models import Appointment, AuditLogEntry, Clinic, WebhookSubscription +from tests.fakes.clock import FixedClock +from tests.integration.conftest import CommitFailure +from tests.integration.helpers import auth_header + +ALL_WEEK = [(wd, "06:00", "22:00") for wd in range(7)] +NOW = datetime(2027, 6, 1, 9, 0, tzinfo=UTC) +CANCEL_SLOT = "2027-06-05T10:00:00+00:00" +BOOK_SLOT = "2027-06-06T10:00:00+00:00" +FAILING_CLINIC = "Commit Fails Clinic" +CONFLICTING_CLINIC = "Commit Conflicts Clinic" +PROBLEM_JSON = "application/problem+json" + +_SUBSCRIPTION = { + "url": "https://example.test/hook", + "secret": "0123456789abcdef", + "event_types": ["appointment.created"], + "clinic_id": None, +} + + +@pytest.fixture +async def world(app: FastAPI, seed: Any, login: Any) -> dict[str, Any]: + """One bookable clinic, a patient and a platform admin. The clock is fixed at ``NOW``.""" + app.dependency_overrides[get_clock] = lambda: FixedClock(NOW) + patient_id = await seed.user(role="PATIENT", email="patient@x.io") + await seed.user(role="PLATFORM_ADMIN", email="admin@x.io") + doc_user = await seed.user(role="CLINICIAN", email="doc@x.io") + clinic_id = await seed.clinic(timezone="UTC", cutoff_hours=24) + clinician_id = await seed.clinician( + clinic_id=clinic_id, user_id=doc_user, working_hours=ALL_WEEK + ) + service_id = await seed.service(clinic_id=clinic_id, duration_minutes=30) + return { + "patient_id": patient_id, + "clinic_id": clinic_id, + "clinician_id": clinician_id, + "service_id": service_id, + "patient_token": await login("patient@x.io"), + "admin_token": await login("admin@x.io"), + } + + +def _booking(world: dict[str, Any], starts_at: str) -> dict[str, str]: + return { + "clinic_id": str(world["clinic_id"]), + "clinician_id": str(world["clinician_id"]), + "service_id": str(world["service_id"]), + "starts_at": starts_at, + } + + +async def _book( + client: httpx.AsyncClient, world: dict[str, Any], starts_at: str +) -> tuple[str, str]: + resp = await client.post( + "/api/v1/appointments", + json=_booking(world, starts_at), + headers=auth_header(world["patient_token"]), + ) + assert resp.status_code == 201, resp.text + return str(resp.json()["id"]), resp.headers["ETag"] + + +async def _subscribe(client: httpx.AsyncClient, world: dict[str, Any]) -> str: + resp = await client.post( + "/api/v1/webhooks/subscriptions", + json=_SUBSCRIPTION, + headers=auth_header(world["admin_token"]), + ) + assert resp.status_code == 201, resp.text + return str(resp.json()["id"]) + + +def _clinic_body(name: str) -> dict[str, Any]: + return { + "name": name, + "timezone": "UTC", + "address": "1 Fail St", + "cancellation_cutoff_hours": 24, + } + + +async def _arm_cancel(fail_commit: CommitFailure, appt_id: str) -> None: + target = uuid.UUID(appt_id) # validated UUID, safe to inline + await fail_commit.arm( + table="appointments", + event="UPDATE", + when=f"NEW.id = '{target}'::uuid AND NEW.status::text = 'CANCELLED'", + ) + + +async def _arm_book(fail_commit: CommitFailure, world: dict[str, Any]) -> None: + patient = uuid.UUID(str(world["patient_id"])) + await fail_commit.arm( + table="appointments", + event="INSERT", + when=f"NEW.patient_id = '{patient}'::uuid AND NEW.starts_at = '{BOOK_SLOT}'::timestamptz", + ) + + +async def _arm_clinic(fail_commit: CommitFailure, name: str, mode: Any = "error") -> None: + await fail_commit.arm(table="clinics", event="INSERT", when=f"NEW.name = '{name}'", mode=mode) + + +async def _arm_unsubscribe(fail_commit: CommitFailure, sub_id: str) -> None: + target = uuid.UUID(sub_id) + await fail_commit.arm( + table="webhook_subscriptions", event="DELETE", when=f"OLD.id = '{target}'::uuid" + ) + + +def _assert_problem(resp: httpx.Response, status: int) -> None: + assert resp.status_code == status, (resp.status_code, resp.text) + assert resp.headers["content-type"].startswith(PROBLEM_JSON), resp.headers + body = resp.json() + assert body["status"] == status, body + assert body["type"].startswith("https://aurora.example/problems/"), body + + +@pytest.mark.agent_trusted +async def test_ac1_api_failed_commit_returns_problem_not_success( + client: httpx.AsyncClient, + raw_client: httpx.AsyncClient, + world: dict[str, Any], + fail_commit: CommitFailure, +) -> None: + patient = auth_header(world["patient_token"]) + admin = auth_header(world["admin_token"]) + appt_id, etag = await _book(client, world, CANCEL_SLOT) + sub_id = await _subscribe(client, world) + await _arm_cancel(fail_commit, appt_id) + await _arm_book(fail_commit, world) + await _arm_clinic(fail_commit, FAILING_CLINIC) + await _arm_clinic(fail_commit, CONFLICTING_CLINIC, mode="integrity") + await _arm_unsubscribe(fail_commit, sub_id) + + # Cancel: 500 problem+json, no CANCELLED body. + resp = await raw_client.post( + f"/api/v1/appointments/{appt_id}/cancel", + json={"reason": "cannot make it"}, + headers={**patient, "If-Match": etag}, + ) + _assert_problem(resp, 500) + assert "CANCELLED" not in resp.text + + # Book: 500 problem+json, not 201. + resp = await raw_client.post( + "/api/v1/appointments", json=_booking(world, BOOK_SLOT), headers=patient + ) + _assert_problem(resp, 500) + + # Create clinic: 500 problem+json. + resp = await raw_client.post( + "/api/v1/clinics", json=_clinic_body(FAILING_CLINIC), headers=admin + ) + _assert_problem(resp, 500) + + # Delete webhook subscription: 500 problem+json, not 204. + resp = await raw_client.delete(f"/api/v1/webhooks/subscriptions/{sub_id}", headers=admin) + _assert_problem(resp, 500) + + # A constraint violation at COMMIT: 409 problem+json. + resp = await raw_client.post( + "/api/v1/clinics", json=_clinic_body(CONFLICTING_CLINIC), headers=admin + ) + _assert_problem(resp, 409) + + +@pytest.mark.agent_trusted +async def test_ac3_data_failed_commit_saves_nothing( + app: FastAPI, + client: httpx.AsyncClient, + raw_client: httpx.AsyncClient, + world: dict[str, Any], + fail_commit: CommitFailure, +) -> None: + patient = auth_header(world["patient_token"]) + admin = auth_header(world["admin_token"]) + appt_id, etag = await _book(client, world, CANCEL_SLOT) + sub_id = await _subscribe(client, world) + await _arm_cancel(fail_commit, appt_id) + await _arm_book(fail_commit, world) + await _arm_clinic(fail_commit, FAILING_CLINIC) + await _arm_unsubscribe(fail_commit, sub_id) + + await raw_client.post( + f"/api/v1/appointments/{appt_id}/cancel", + json={"reason": "cannot make it"}, + headers={**patient, "If-Match": etag}, + ) + await raw_client.post("/api/v1/appointments", json=_booking(world, BOOK_SLOT), headers=patient) + await raw_client.post("/api/v1/clinics", json=_clinic_body(FAILING_CLINIC), headers=admin) + await raw_client.delete(f"/api/v1/webhooks/subscriptions/{sub_id}", headers=admin) + + maker: async_sessionmaker[AsyncSession] = app.state.sessionmaker + async with maker() as session: + # Cancel: still REQUESTED, reason unchanged, and no audit row for it. + appointment = await session.get(Appointment, uuid.UUID(appt_id)) + assert appointment is not None + assert appointment.status.value == "REQUESTED" + assert appointment.cancellation_reason is None + audit_rows = await session.scalar( + select(func.count()) + .select_from(AuditLogEntry) + .where(AuditLogEntry.entity_id == appt_id) + ) + assert audit_rows == 0 + + # Book: no appointment for that patient and slot. + booked = await session.scalar( + select(func.count()) + .select_from(Appointment) + .where( + Appointment.patient_id == world["patient_id"], + Appointment.starts_at == datetime.fromisoformat(BOOK_SLOT), + ) + ) + assert booked == 0 + + # Create clinic: no clinic with that name. + clinics = await session.scalar( + select(func.count()).select_from(Clinic).where(Clinic.name == FAILING_CLINIC) + ) + assert clinics == 0 + + # Delete webhook subscription: it still exists. + assert await session.get(WebhookSubscription, uuid.UUID(sub_id)) is not None From b246054963351c7af9ba2f39a6c9a686845b35f5 Mon Sep 17 00:00:00 2001 From: evg-g <229170690+evg-g@users.noreply.github.com> Date: Sun, 4 Oct 2026 10:21:16 +0300 Subject: [PATCH 3/7] feat(webhooks): AURORA-3 AC4.data publish webhook events after commit Co-Authored-By: Claude Opus 5.5 --- docs/KNOWN_GAPS.md | 12 +-- src/appointments_api/api/deps.py | 5 +- .../api/routers/appointments.py | 26 +++++-- src/appointments_api/db.py | 19 ++++- .../services/transaction_hooks.py | 56 +++++++++++++ tests/integration/test_commit_failure.py | 78 +++++++++++++++++++ 6 files changed, 182 insertions(+), 14 deletions(-) create mode 100644 src/appointments_api/services/transaction_hooks.py diff --git a/docs/KNOWN_GAPS.md b/docs/KNOWN_GAPS.md index fcc1632..e7b7c14 100644 --- a/docs/KNOWN_GAPS.md +++ b/docs/KNOWN_GAPS.md @@ -29,12 +29,12 @@ focused on the surface; tracked here so it is not forgotten. ## Webhook emission is not transactional with the DB commit -A state change publishes its event to Redis during the request, after the row has flushed but before -the surrounding transaction commits. A crash in that small window could emit an event for a change -that later rolled back, or commit a change whose event was not emitted. Receivers already must dedupe -on `X-Webhook-Id` (delivery is at-least-once), but exactly-once *emission* needs a transactional -outbox: write the event to a DB table in the same transaction, and have a relay publish it. Deliberately -out of scope for milestone 4; see ADR 0010. +A state change publishes its event to Redis from an `on_commit` transaction hook, after the COMMIT +succeeds (ADR 0017), so a failed COMMIT never emits an event. One window remains: a crash, or a Redis +failure, between the COMMIT and the hook loses that event (a failed publish is logged, the change +stays saved). Receivers already must dedupe on `X-Webhook-Id` (delivery is at-least-once), but +exactly-once *emission* needs a transactional outbox: write the event to a DB table in the same +transaction, and have a relay publish it. Deferred to AURORA-4; see ADR 0010 and ADR 0017. ## Webhook worker runs on demand, and dead letters are not yet surfaced diff --git a/src/appointments_api/api/deps.py b/src/appointments_api/api/deps.py index 37eb476..465cb5e 100644 --- a/src/appointments_api/api/deps.py +++ b/src/appointments_api/api/deps.py @@ -18,7 +18,7 @@ from appointments_api.api.errors import ForbiddenError, UnauthorizedError from appointments_api.api.pagination import Cursor, decode_cursor from appointments_api.config import Settings, get_settings -from appointments_api.db import get_redis, get_session +from appointments_api.db import get_redis, get_session, get_transaction_hooks from appointments_api.enums import UserRole from appointments_api.models import Device, User from appointments_api.repositories.appointments import AppointmentRepository @@ -41,6 +41,7 @@ from appointments_api.services.idempotency import IdempotencyService from appointments_api.services.telemetry.ingestion import TelemetryIngestionService from appointments_api.services.tokens import TokenService +from appointments_api.services.transaction_hooks import TransactionHooks from appointments_api.services.webhooks.dispatcher import WebhookDispatcher from appointments_api.telemetry_wiring import build_ingestion_service @@ -50,6 +51,8 @@ SessionDep = Annotated[AsyncSession, Depends(get_session, scope="function")] RedisDep = Annotated[Redis, Depends(get_redis)] SettingsDep = Annotated[Settings, Depends(get_settings)] +# The same object get_session runs after COMMIT (on_commit) or after rollback (on_rollback). +TransactionHooksDep = Annotated[TransactionHooks, Depends(get_transaction_hooks)] def get_user_repository(session: SessionDep) -> UserRepository: diff --git a/src/appointments_api/api/routers/appointments.py b/src/appointments_api/api/routers/appointments.py index 264cc09..77fb392 100644 --- a/src/appointments_api/api/routers/appointments.py +++ b/src/appointments_api/api/routers/appointments.py @@ -33,6 +33,7 @@ IdempotencyServiceDep, PageParams, ServiceRepoDep, + TransactionHooksDep, UserRepoDep, WebhookDispatcherDep, ) @@ -65,6 +66,7 @@ StoredResponse, request_fingerprint, ) +from appointments_api.services.transaction_hooks import TransactionHooks from appointments_api.services.webhooks.dispatcher import WebhookDispatcher from appointments_api.services.webhooks.events import ( WebhookEvent, @@ -99,20 +101,29 @@ def _replayed_response(stored: StoredResponse) -> JSONResponse: return JSONResponse(status_code=stored.status_code, content=stored.body, headers=headers) -async def _emit( +def _emit( + hooks: TransactionHooks, dispatcher: WebhookDispatcher, clock: Clock, event_type: WebhookEventType, appointment: Appointment, ) -> None: - """Publish a state-change event for the webhook worker to fan out and deliver.""" + """Publish a state-change event for the webhook worker, once the transaction has committed. + + The event is built now (from the flushed row the response also shows) but published by an + ``on_commit`` hook, so a failed COMMIT publishes nothing (ADR 0017). + """ event = WebhookEvent( id=uuid.uuid4().hex, type=event_type.value, occurred_at=clock.now(), data={"appointment": _serialize(appointment)}, ) - await dispatcher.dispatch(event) + + async def publish() -> None: + await dispatcher.dispatch(event) + + hooks.on_commit(publish) async def _assert_can_access( @@ -220,6 +231,7 @@ async def create_appointment( appointments: AppointmentRepoDep, idempotency: IdempotencyServiceDep, dispatcher: WebhookDispatcherDep, + hooks: TransactionHooksDep, clock: ClockDep, idempotency_key: Annotated[str | None, Header(alias="Idempotency-Key")] = None, ) -> Response: @@ -260,7 +272,7 @@ async def create_appointment( etag=make_etag(appointment.version), ), ) - await _emit(dispatcher, clock, WebhookEventType.APPOINTMENT_CREATED, appointment) + _emit(hooks, dispatcher, clock, WebhookEventType.APPOINTMENT_CREATED, appointment) return _appointment_response(appointment, status_code=status.HTTP_201_CREATED) @@ -321,6 +333,7 @@ async def transition_appointment( appointments: AppointmentRepoDep, clinicians: ClinicianRepoDep, dispatcher: WebhookDispatcherDep, + hooks: TransactionHooksDep, clock: ClockDep, if_match: Annotated[str | None, Header(alias="If-Match")] = None, ) -> Response: @@ -338,7 +351,7 @@ async def transition_appointment( await appointments.flush() event_type = event_type_for_status(appointment.status) if event_type is not None: - await _emit(dispatcher, clock, event_type, appointment) + _emit(hooks, dispatcher, clock, event_type, appointment) return _appointment_response(appointment) @@ -352,6 +365,7 @@ async def cancel_appointment( clinicians: ClinicianRepoDep, clock: ClockDep, dispatcher: WebhookDispatcherDep, + hooks: TransactionHooksDep, audit_log: AuditLogRepoDep, if_match: Annotated[str | None, Header(alias="If-Match")] = None, ) -> Response: @@ -386,5 +400,5 @@ async def cancel_appointment( before={"status": previous_status.value}, after={"status": AppointmentStatus.CANCELLED.value}, ) - await _emit(dispatcher, clock, WebhookEventType.APPOINTMENT_CANCELLED, appointment) + _emit(hooks, dispatcher, clock, WebhookEventType.APPOINTMENT_CANCELLED, appointment) return _appointment_response(appointment) diff --git a/src/appointments_api/db.py b/src/appointments_api/db.py index b684514..a41edb7 100644 --- a/src/appointments_api/db.py +++ b/src/appointments_api/db.py @@ -20,6 +20,7 @@ from starlette.requests import Request from appointments_api.config import Settings, get_settings +from appointments_api.services.transaction_hooks import TransactionHooks def create_engine(settings: Settings | None = None) -> AsyncEngine: @@ -37,16 +38,32 @@ def create_redis(settings: Settings | None = None) -> Redis: return client +def get_transaction_hooks(request: Request) -> TransactionHooks: + """The request's ``TransactionHooks``, created on first use and shared for the request.""" + hooks: TransactionHooks | None = getattr(request.state, "transaction_hooks", None) + if hooks is None: + hooks = TransactionHooks() + request.state.transaction_hooks = hooks + return hooks + + async def get_session(request: Request) -> AsyncIterator[AsyncSession]: - """Yield a session, committing on success and rolling back on any error.""" + """Yield a session, committing on success and rolling back on any error. + + After the COMMIT succeeds the request's ``on_commit`` hooks run; after a rollback its + ``on_rollback`` hooks run (ADR 0017). A failing hook is logged and does not fail the request. + """ maker: async_sessionmaker[AsyncSession] = request.app.state.sessionmaker + hooks = get_transaction_hooks(request) async with maker() as session: try: yield session await session.commit() except Exception: await session.rollback() + await hooks.run_rollback_hooks() raise + await hooks.run_commit_hooks() def get_redis(request: Request) -> Redis: diff --git a/src/appointments_api/services/transaction_hooks.py b/src/appointments_api/services/transaction_hooks.py new file mode 100644 index 0000000..3de5fee --- /dev/null +++ b/src/appointments_api/services/transaction_hooks.py @@ -0,0 +1,56 @@ +"""Callbacks that run once the request's database transaction ends (ADR 0017). + +A request-scoped ``TransactionHooks`` holds two lists. ``on_commit`` hooks run only after the +COMMIT succeeds, so side effects outside the database (a webhook event, an idempotency record) +never describe a change that was not saved. ``on_rollback`` hooks run after a rollback, to undo +work done during the request outside the database (for example, release an idempotency key). + +Each hook runs on its own: a hook that raises is logged and the rest still run. The transaction +has already ended when hooks run, so a failing hook must not change the response. +""" + +from __future__ import annotations + +from collections.abc import Awaitable, Callable + +import structlog + +Hook = Callable[[], Awaitable[None]] + +logger = structlog.get_logger(__name__) + + +class TransactionHooks: + def __init__(self) -> None: + self._on_commit: list[Hook] = [] + self._on_rollback: list[Hook] = [] + + def on_commit(self, hook: Hook) -> None: + """Run ``hook`` after the transaction commits. Never runs if it rolls back.""" + self._on_commit.append(hook) + + def on_rollback(self, hook: Hook) -> None: + """Run ``hook`` after the transaction rolls back. Never runs if it commits.""" + self._on_rollback.append(hook) + + async def run_commit_hooks(self) -> None: + hooks = self._take() + await _run_each(hooks[0], phase="on_commit") + + async def run_rollback_hooks(self) -> None: + hooks = self._take() + await _run_each(hooks[1], phase="on_rollback") + + def _take(self) -> tuple[list[Hook], list[Hook]]: + # Clear both lists so each hook runs at most once, whichever way the transaction ended. + taken = (self._on_commit, self._on_rollback) + self._on_commit, self._on_rollback = [], [] + return taken + + +async def _run_each(hooks: list[Hook], *, phase: str) -> None: + for hook in hooks: + try: + await hook() + except Exception: + logger.exception("transaction_hook_failed", phase=phase, hook=repr(hook)) diff --git a/tests/integration/test_commit_failure.py b/tests/integration/test_commit_failure.py index efd11c2..f109747 100644 --- a/tests/integration/test_commit_failure.py +++ b/tests/integration/test_commit_failure.py @@ -8,6 +8,7 @@ from __future__ import annotations +import json import uuid from datetime import UTC, datetime from typing import Any @@ -20,6 +21,7 @@ from appointments_api.api.deps import get_clock from appointments_api.models import Appointment, AuditLogEntry, Clinic, WebhookSubscription +from appointments_api.repositories.redis_webhooks import RedisEventQueue from tests.fakes.clock import FixedClock from tests.integration.conftest import CommitFailure from tests.integration.helpers import auth_header @@ -28,6 +30,8 @@ NOW = datetime(2027, 6, 1, 9, 0, tzinfo=UTC) CANCEL_SLOT = "2027-06-05T10:00:00+00:00" BOOK_SLOT = "2027-06-06T10:00:00+00:00" +CONFIRM_SLOT = "2027-06-07T10:00:00+00:00" +CANCEL_OK_SLOT = "2027-06-08T10:00:00+00:00" FAILING_CLINIC = "Commit Fails Clinic" CONFLICTING_CLINIC = "Commit Conflicts Clinic" PROBLEM_JSON = "application/problem+json" @@ -247,3 +251,77 @@ async def test_ac3_data_failed_commit_saves_nothing( # Delete webhook subscription: it still exists. assert await session.get(WebhookSubscription, uuid.UUID(sub_id)) is not None + + +async def _queued_events(app: FastAPI) -> list[dict[str, Any]]: + raw = await app.state.redis.lrange(RedisEventQueue.KEY, 0, -1) + return [json.loads(item) for item in raw] + + +@pytest.mark.agent_trusted +async def test_ac4_data_failed_commit_publishes_no_webhook_event( + app: FastAPI, + client: httpx.AsyncClient, + raw_client: httpx.AsyncClient, + world: dict[str, Any], + fail_commit: CommitFailure, +) -> None: + """Only the Redis event queue (testcontainer) is read; no worker runs, nothing is delivered.""" + patient = auth_header(world["patient_token"]) + admin = auth_header(world["admin_token"]) + cancel_id, cancel_etag = await _book(client, world, CANCEL_SLOT) + confirm_id, confirm_etag = await _book(client, world, CONFIRM_SLOT) + ok_id, ok_etag = await _book(client, world, CANCEL_OK_SLOT) + await _arm_cancel(fail_commit, cancel_id) + await _arm_book(fail_commit, world) + await fail_commit.arm( + table="appointments", + event="UPDATE", + when=f"NEW.id = '{uuid.UUID(confirm_id)}'::uuid AND NEW.status::text = 'CONFIRMED'", + ) + # Drop the appointment.created events from the setup bookings. + await app.state.redis.delete(RedisEventQueue.KEY) + + # Cancel, COMMIT fails: no appointment.cancelled event. + resp = await raw_client.post( + f"/api/v1/appointments/{cancel_id}/cancel", + json={"reason": "cannot make it"}, + headers={**patient, "If-Match": cancel_etag}, + ) + assert resp.status_code == 500, resp.text + events = await _queued_events(app) + assert [e for e in events if e["type"] == "appointment.cancelled"] == [], events + + # Book, COMMIT fails: no appointment.created event. + resp = await raw_client.post( + "/api/v1/appointments", json=_booking(world, BOOK_SLOT), headers=patient + ) + assert resp.status_code == 500, resp.text + events = await _queued_events(app) + assert [e for e in events if e["type"] == "appointment.created"] == [], events + + # Transition to CONFIRMED, COMMIT fails: no appointment.confirmed event. + resp = await raw_client.post( + f"/api/v1/appointments/{confirm_id}/transition", + json={"target_status": "CONFIRMED"}, + headers={**admin, "If-Match": confirm_etag}, + ) + assert resp.status_code == 500, resp.text + events = await _queued_events(app) + assert [e for e in events if e["type"] == "appointment.confirmed"] == [], events + assert events == [], events + + # Cancel, COMMIT succeeds: exactly one appointment.cancelled event, matching the saved row. + resp = await raw_client.post( + f"/api/v1/appointments/{ok_id}/cancel", + json={"reason": "changed plans"}, + headers={**patient, "If-Match": ok_etag}, + ) + assert resp.status_code == 200, resp.text + events = await _queued_events(app) + assert len(events) == 1, events + assert events[0]["type"] == "appointment.cancelled" + saved = await client.get(f"/api/v1/appointments/{ok_id}", headers=admin) + assert saved.status_code == 200, saved.text + assert saved.json()["status"] == "CANCELLED" + assert events[0]["data"]["appointment"] == saved.json() From 63bdeacfe5fda67c89d4fa6548c4502e81575ba1 Mon Sep 17 00:00:00 2001 From: evg-g <229170690+evg-g@users.noreply.github.com> Date: Sun, 4 Oct 2026 10:23:41 +0300 Subject: [PATCH 4/7] feat(appointments): AURORA-3 AC2.api free the idempotency key when the booking commit fails Co-Authored-By: Claude Opus 5.5 --- .../api/routers/appointments.py | 46 ++++++++--- tests/integration/test_commit_failure.py | 81 +++++++++++++++++++ 2 files changed, 115 insertions(+), 12 deletions(-) diff --git a/src/appointments_api/api/routers/appointments.py b/src/appointments_api/api/routers/appointments.py index 77fb392..1af189d 100644 --- a/src/appointments_api/api/routers/appointments.py +++ b/src/appointments_api/api/routers/appointments.py @@ -63,6 +63,7 @@ from appointments_api.services.clock import Clock from appointments_api.services.idempotency import ( BeginState, + IdempotencyService, StoredResponse, request_fingerprint, ) @@ -126,6 +127,28 @@ async def publish() -> None: hooks.on_commit(publish) +def _register_idempotency_release( + hooks: TransactionHooks, idempotency: IdempotencyService, key: str, fingerprint: str +) -> None: + async def release() -> None: + await idempotency.release(_IDEMPOTENCY_SCOPE, key, fingerprint) + + hooks.on_rollback(release) + + +def _register_idempotency_complete( + hooks: TransactionHooks, + idempotency: IdempotencyService, + key: str, + fingerprint: str, + response: StoredResponse, +) -> None: + async def complete() -> None: + await idempotency.complete(_IDEMPOTENCY_SCOPE, key, fingerprint, response) + + hooks.on_commit(complete) + + async def _assert_can_access( user: User, appointment: Appointment, clinicians: ClinicianRepoDep ) -> None: @@ -249,21 +272,20 @@ async def create_appointment( raise IdempotencyKeyReusedError( "This Idempotency-Key was already used for a different request body." ) + # This request now holds the key. If the transaction rolls back (the handler fails or the + # COMMIT is refused), drop the pending marker so a retry, with any body, starts fresh: a + # failed attempt must not poison the key (ADR 0017). + _register_idempotency_release(hooks, idempotency, idempotency_key, fingerprint) - try: - appointment = await _build_appointment( - body, current, users, clinics, clinicians, services, appointments - ) - except Exception: - # A failed attempt must not poison the key: drop the pending marker so a corrected retry - # can start fresh. - if idempotency_key is not None and fingerprint is not None: - await idempotency.release(_IDEMPOTENCY_SCOPE, idempotency_key, fingerprint) - raise + appointment = await _build_appointment( + body, current, users, clinics, clinicians, services, appointments + ) if idempotency_key is not None and fingerprint is not None: - await idempotency.complete( - _IDEMPOTENCY_SCOPE, + # Store the 201 for replay only once the COMMIT has succeeded (ADR 0017). + _register_idempotency_complete( + hooks, + idempotency, idempotency_key, fingerprint, StoredResponse( diff --git a/tests/integration/test_commit_failure.py b/tests/integration/test_commit_failure.py index f109747..902d8a4 100644 --- a/tests/integration/test_commit_failure.py +++ b/tests/integration/test_commit_failure.py @@ -325,3 +325,84 @@ async def test_ac4_data_failed_commit_publishes_no_webhook_event( assert saved.status_code == 200, saved.text assert saved.json()["status"] == "CANCELLED" assert events[0]["data"]["appointment"] == saved.json() + + +RETRY_SLOT = "2027-06-09T10:00:00+00:00" +OTHER_BODY_SLOT = "2027-06-10T10:00:00+00:00" + + +async def _arm_book_at(fail_commit: CommitFailure, world: dict[str, Any], starts_at: str) -> None: + patient = uuid.UUID(str(world["patient_id"])) + slot = datetime.fromisoformat(starts_at).isoformat() # validated timestamp, safe to inline + await fail_commit.arm( + table="appointments", + event="INSERT", + when=f"NEW.patient_id = '{patient}'::uuid AND NEW.starts_at = '{slot}'::timestamptz", + ) + + +async def _patient_slots(client: httpx.AsyncClient, world: dict[str, Any]) -> list[datetime]: + resp = await client.get("/api/v1/appointments", headers=auth_header(world["patient_token"])) + assert resp.status_code == 200, resp.text + return [datetime.fromisoformat(a["starts_at"]) for a in resp.json()["data"]] + + +@pytest.mark.agent_trusted +async def test_ac2_api_failed_commit_frees_the_idempotency_key( + raw_client: httpx.AsyncClient, + world: dict[str, Any], + fail_commit: CommitFailure, +) -> None: + patient = auth_header(world["patient_token"]) + key = {"Idempotency-Key": "commit-fails-k"} + + # Book with key K and the COMMIT fails: 500 problem+json, and no appointment is found. + await _arm_book_at(fail_commit, world, RETRY_SLOT) + resp = await raw_client.post( + "/api/v1/appointments", json=_booking(world, RETRY_SLOT), headers={**patient, **key} + ) + _assert_problem(resp, 500) + assert datetime.fromisoformat(RETRY_SLOT) not in await _patient_slots(raw_client, world) + + # Retry the same body with K and the COMMIT succeeds: 201 with a new, saved appointment. + await fail_commit.disarm() + retry = await raw_client.post( + "/api/v1/appointments", json=_booking(world, RETRY_SLOT), headers={**patient, **key} + ) + assert retry.status_code == 201, retry.text + assert retry.headers.get("idempotency-replayed") is None, retry.headers + appt_id = retry.json()["id"] + saved = await raw_client.get(f"/api/v1/appointments/{appt_id}", headers=patient) + assert saved.status_code == 200, saved.text + assert saved.json()["id"] == appt_id + assert datetime.fromisoformat(saved.json()["starts_at"]) == datetime.fromisoformat(RETRY_SLOT) + + # COMMIT succeeded, retry the same body with K: 201 replay with the same appointment id. + replay = await raw_client.post( + "/api/v1/appointments", json=_booking(world, RETRY_SLOT), headers={**patient, **key} + ) + assert replay.status_code == 201, replay.text + assert replay.headers.get("idempotency-replayed") == "true", replay.headers + assert replay.json()["id"] == appt_id + + # Book with a second key K2 and the COMMIT fails, then a different body with K2: 201, not 422. + other_key = {"Idempotency-Key": "commit-fails-k2"} + await _arm_book_at(fail_commit, world, BOOK_SLOT) + resp = await raw_client.post( + "/api/v1/appointments", json=_booking(world, BOOK_SLOT), headers={**patient, **other_key} + ) + _assert_problem(resp, 500) + different = await raw_client.post( + "/api/v1/appointments", + json=_booking(world, OTHER_BODY_SLOT), + headers={**patient, **other_key}, + ) + assert different.status_code == 201, different.text + assert different.headers.get("idempotency-replayed") is None, different.headers + new_id = different.json()["id"] + assert new_id != appt_id + saved = await raw_client.get(f"/api/v1/appointments/{new_id}", headers=patient) + assert saved.status_code == 200, saved.text + assert datetime.fromisoformat(saved.json()["starts_at"]) == datetime.fromisoformat( + OTHER_BODY_SLOT + ) From c05049a9fd793eab70b914977584cf02fd1b45a0 Mon Sep 17 00:00:00 2001 From: evg-g <229170690+evg-g@users.noreply.github.com> Date: Sun, 4 Oct 2026 10:24:40 +0300 Subject: [PATCH 5/7] docs: AURORA-3 catalog internal-error and mark ADR 0007/0010 timing superseded by ADR 0017 Co-Authored-By: Claude Opus 5.5 --- docs/ERROR_CATALOG.md | 1 + docs/adr/0007-idempotency-keys.md | 3 ++- docs/adr/0010-signed-webhooks-and-delivery-worker.md | 3 ++- 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/docs/ERROR_CATALOG.md b/docs/ERROR_CATALOG.md index 0ef2fa7..483b648 100644 --- a/docs/ERROR_CATALOG.md +++ b/docs/ERROR_CATALOG.md @@ -19,6 +19,7 @@ Every error response uses `application/problem+json` (RFC 9457). The body always | `validation-error` | 422 | Request body/query failed validation, or a business rule rejected the input (e.g. time outside working hours). `errors[]` lists the offending fields. | | `idempotency-key-reused` | 422 | An `Idempotency-Key` was replayed with a different request body. | | `rate-limited` | 429 | The principal exceeded its rate-limit window. Carries `Retry-After` and `RateLimit-*` headers. | +| `internal-error` | 500 | An unexpected server error, including a COMMIT the database refused (ADR 0017). Nothing from the request is saved. | ## Examples diff --git a/docs/adr/0007-idempotency-keys.md b/docs/adr/0007-idempotency-keys.md index 65c1860..2188a77 100644 --- a/docs/adr/0007-idempotency-keys.md +++ b/docs/adr/0007-idempotency-keys.md @@ -1,6 +1,7 @@ # 7. Idempotency-Key on create -- Status: accepted +- Status: accepted; the timing of complete/release is superseded by ADR 0017 (they run after + COMMIT / rollback) - Date: 2026-09-27 ## Context diff --git a/docs/adr/0010-signed-webhooks-and-delivery-worker.md b/docs/adr/0010-signed-webhooks-and-delivery-worker.md index 62d98f8..a68d345 100644 --- a/docs/adr/0010-signed-webhooks-and-delivery-worker.md +++ b/docs/adr/0010-signed-webhooks-and-delivery-worker.md @@ -1,6 +1,7 @@ # 10. Signed webhooks with a retrying delivery worker -- Status: accepted +- Status: accepted; the emission-timing consequence is superseded by ADR 0017 (events publish + after COMMIT) - Date: 2026-09-27 ## Context From a50c7f96023168c10aa004f4ea8413bcf7728feb Mon Sep 17 00:00:00 2001 From: evg-g <229170690+evg-g@users.noreply.github.com> Date: Sun, 4 Oct 2026 10:45:44 +0300 Subject: [PATCH 6/7] test(webhooks): AURORA-3 use a plain-words fake webhook secret The random-looking hex secret tripped gitleaks generic-api-key. Co-Authored-By: Claude Opus 5.5 --- tests/integration/test_commit_failure.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/integration/test_commit_failure.py b/tests/integration/test_commit_failure.py index 902d8a4..b4f9273 100644 --- a/tests/integration/test_commit_failure.py +++ b/tests/integration/test_commit_failure.py @@ -38,7 +38,7 @@ _SUBSCRIPTION = { "url": "https://example.test/hook", - "secret": "0123456789abcdef", + "secret": "test-secret-not-real", "event_types": ["appointment.created"], "clinic_id": None, } From f3f7da6c019da1856f0eff30db26c1584a391c14 Mon Sep 17 00:00:00 2001 From: evg-g <229170690+evg-g@users.noreply.github.com> Date: Sun, 4 Oct 2026 11:27:57 +0300 Subject: [PATCH 7/7] ci(security): AURORA-3 ignore the reviewed fake webhook secret in gitleaks Co-Authored-By: Claude Opus 5.5 --- .gitleaksignore | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.gitleaksignore b/.gitleaksignore index 88b9597..20c6879 100644 --- a/.gitleaksignore +++ b/.gitleaksignore @@ -13,3 +13,8 @@ # the literal "0123456789abcdef". A sequential hex string written to be readable in test output; # it signs nothing outside the test process. Flagged by the generic-api-key rule on entropy alone. 7a2b78bba835061243e0d8b24acdffd09ffec1d0:tests/integration/test_webhooks_api.py:generic-api-key:27 + +# tests/integration/test_commit_failure.py:37 — the same fake webhook test secret "0123456789abcdef", +# copied into the AURORA-3 commit-failure tests. It signs nothing outside the test process. Flagged +# by the generic-api-key rule on entropy alone; the value was replaced in a50c7f9. +52380ec296e26e2a6ae13701064b1b611d74bd9e:tests/integration/test_commit_failure.py:generic-api-key:37