PH1.3 — API platform: pagination, idempotency, ETag, soft-delete repository, crud_router factory #11

Closed
opened 2026-09-11 09:02:09 +00:00 by bart · 2 comments
Collaborator

Parent

Row PH1.3 in docs/12-implementation-plan.md (branch, owned paths and doc section are defined there).

What to build

Any list endpoint returns {items, total} with filter and sort parsing; mutating endpoints accept an Idempotency-Key and If-Match/ETag, require a reason, and write an audit row in the same transaction. A generic crud_router(...) builds a soft-delete CRUD router for a master-data aggregate from its model and DTOs.

Acceptance criteria

  • A crud_router over a test aggregate serves list/get/create/update/soft-delete with role checks
  • Replaying a request with the same Idempotency-Key returns the stored response and performs no second write
  • A stale If-Match yields 412; a matching one succeeds and the ETag changes
  • Every mutation writes an audit row with actor, reason and diff in the same transaction; a missing reason is a 422
  • Soft-deleted rows are excluded from lists by default and never hard-deleted

Blocked by

  • #4 — API skeleton: core, router discovery, healthz, db + audit hook, alembic 0001, CLI
  • #7 — Keycloak auth: JWKS bearer validation, require_role, GET /me, LDAP smoke test
## Parent Row **PH1.3** in `docs/12-implementation-plan.md` (branch, owned paths and doc section are defined there). ## What to build Any list endpoint returns `{items, total}` with filter and sort parsing; mutating endpoints accept an `Idempotency-Key` and `If-Match`/ETag, require a `reason`, and write an audit row in the same transaction. A generic `crud_router(...)` builds a soft-delete CRUD router for a master-data aggregate from its model and DTOs. ## Acceptance criteria - [x] A `crud_router` over a test aggregate serves list/get/create/update/soft-delete with role checks - [x] Replaying a request with the same `Idempotency-Key` returns the stored response and performs no second write - [x] A stale `If-Match` yields 412; a matching one succeeds and the ETag changes - [x] Every mutation writes an audit row with actor, reason and diff in the same transaction; a missing `reason` is a 422 - [x] Soft-deleted rows are excluded from lists by default and never hard-deleted ## Blocked by - #4 — API skeleton: core, router discovery, healthz, db + audit hook, alembic 0001, CLI - #7 — Keycloak auth: JWKS bearer validation, require_role, GET /me, LDAP smoke test
bart self-assigned this 2026-09-11 11:25:13 +00:00
Author
Collaborator

Starting PH1.3 on feature/api-platform (branch already existed at zero commits, branched from main).

Owned paths for this ticket: backend/src/polaris/api/{pagination,idempotency,etag,crud}.py, backend/src/polaris/db/repository.py, backend/tests/unit/api/**. Not touching api/main.py, api/auth.py, or any db/models/*.py.

Work is done; PR incoming shortly. Two small, isolated, flagged touches to files this ticket doesn't own were needed to complete the reason -> audit_log.reason plumbing: polaris/core/context.py (new reason_var contextvar, mirroring actor_sub_var/actor_role_var) and polaris/db/audit.py (one-line read of it in _audit_row). Also added PreconditionFailedError to polaris/core/errors.py (412, same pattern as the existing error classes) since the ticket brief explicitly sanctioned extending that file. See the PR description for the full reasoning.

Starting PH1.3 on `feature/api-platform` (branch already existed at zero commits, branched from `main`). Owned paths for this ticket: `backend/src/polaris/api/{pagination,idempotency,etag,crud}.py`, `backend/src/polaris/db/repository.py`, `backend/tests/unit/api/**`. Not touching `api/main.py`, `api/auth.py`, or any `db/models/*.py`. Work is done; PR incoming shortly. Two small, isolated, flagged touches to files this ticket doesn't own were needed to complete the `reason` -> `audit_log.reason` plumbing: `polaris/core/context.py` (new `reason_var` contextvar, mirroring `actor_sub_var`/`actor_role_var`) and `polaris/db/audit.py` (one-line read of it in `_audit_row`). Also added `PreconditionFailedError` to `polaris/core/errors.py` (412, same pattern as the existing error classes) since the ticket brief explicitly sanctioned extending that file. See the PR description for the full reasoning.
Author
Collaborator

Idempotency-store persistence — design call, flagged as requested.

The ticket's own remit doesn't include backend/alembic/versions/**, and 0003 (the next free migration slot) is already claimed by PH1.2 / feature/db-schema-planning (per docs/12-implementation-plan.md's Wave 1a row: "PH1.1 -> PH1.2 ... One session does both, back to back."). So polaris.api.idempotency.InMemoryIdempotencyStore is in-process, not a database table:

  • Thread-safe, TTL-evicting (24h per docs/09-api.md) dict, keyed by actor_sub:method:path:Idempotency-Key.
  • Correct (never replays a wrong/stale response) but not durable: an API process restart forgets in-flight keys, and with >1 replica behind a load balancer a retry landing on a different instance won't see the original's cache.
  • Both failure modes degrade to "no idempotency protection for that one retry" -- the same as having no Idempotency-Key support at all -- never to a wrong or double-counted result. Strictly better than nothing for a single-instance dev/staging deployment; not sufficient once there's more than one API replica in front of a load balancer.

Seam request: whoever takes the next free migration slot after 0003 should add a durable idempotency_key table (same key shape) and swap _default_store in polaris/api/idempotency.py for a table-backed implementation of the same IdempotencyStore protocol -- no caller-side changes needed, crud_router only depends on the protocol. Opening this as a heads-up here rather than a separate issue since there's no ticket yet for "whatever comes after PH1.2's migration" to attach it to; happy to open a proper seam-request issue once that next migration-owning ticket exists, or a maintainer can fold it into that ticket's brief directly.

**Idempotency-store persistence — design call, flagged as requested.** The ticket's own remit doesn't include `backend/alembic/versions/**`, and `0003` (the next free migration slot) is already claimed by PH1.2 / `feature/db-schema-planning` (per `docs/12-implementation-plan.md`'s Wave 1a row: "PH1.1 -> PH1.2 ... One session does both, back to back."). So `polaris.api.idempotency.InMemoryIdempotencyStore` is in-process, not a database table: - Thread-safe, TTL-evicting (24h per docs/09-api.md) dict, keyed by `actor_sub:method:path:Idempotency-Key`. - Correct (never replays a wrong/stale response) but not durable: an API process restart forgets in-flight keys, and with >1 replica behind a load balancer a retry landing on a different instance won't see the original's cache. - Both failure modes degrade to "no idempotency protection for that one retry" -- the same as having no `Idempotency-Key` support at all -- never to a wrong or double-counted result. Strictly better than nothing for a single-instance dev/staging deployment; not sufficient once there's more than one API replica in front of a load balancer. **Seam request**: whoever takes the next free migration slot after `0003` should add a durable `idempotency_key` table (same key shape) and swap `_default_store` in `polaris/api/idempotency.py` for a table-backed implementation of the same `IdempotencyStore` protocol -- no caller-side changes needed, `crud_router` only depends on the protocol. Opening this as a heads-up here rather than a separate issue since there's no ticket yet for "whatever comes after PH1.2's migration" to attach it to; happy to open a proper seam-request issue once that next migration-owning ticket exists, or a maintainer can fold it into that ticket's brief directly.
bart closed this issue 2026-09-11 11:50:08 +00:00
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Reference
patrick/Polaris#11
No description provided.