Skip to content

feat: serve controls and their history over the API - #4

Merged
quality-runtime[bot] merged 1 commit into
mainfrom
feat/controls-api
Sep 18, 2026
Merged

quality-runtime[bot] merged 1 commit into
mainfrom
feat/controls-api

Conversation

@quality-runtime

Copy link
Copy Markdown
Contributor

The first part of the requirement → control → evidence loop reachable over HTTP, on the database enforcement from #3. Controls and their audit history are served; standards and mapping, evidence and attestation, and files follow in the next PRs, since their tables are already on main.

API core

  • Tenant from the path. Every tenant-owned route sits under /api/v1/organizations/{organizationId}. A middleware resolves the caller's member row first and binds withOrganization to that organization, so a handler can neither reach another tenant nor run without one. A non-member gets 404, not 403 (ADR 0004).
  • One error envelope ({ error: { code, message, details } }), Zod validation of bodies and queries, and a 64 KiB body limit decided before anything is parsed.
  • Cursor paging by (created_at, id), newest first. The cursor is opaque and scoped to the ordering it came from; it is validated for shape but deliberately unsigned, because row-level security already bounds what any position can reach (ADR 0006).
  • Audit in the same transaction as every mutation. A change made while impersonating is attributed to the administrator, by id and name, with the member recorded as whose account it went through (ADR 0005).

Controls

Route Behaviour
GET/POST /controls List (paged) and create. A control always starts as a draft.
GET /controls/{id} Read, with an ETag from the row's xmin.
PATCH /controls/{id} Change name, description or status along draft → active → retired, one way. A request that changes nothing writes nothing, so it cannot stale another client's tag.
DELETE /controls/{id} Discard a draft that never took effect: 409 otherwise, or if it carries evidence.

Amending and discarding accept optional If-Match, parsed to RFC 9110 and evaluated after the other refusals (ADR 0019). Both lock the row before deciding (ADR 0017).

PATCH /api/v1/organizations/org_…/controls/ctl_…
If-Match: "74219"

{ "status": "active" }

History and API description

  • GET /history serves the organization's audit trail, narrowed to one record with ?resource=. There is no per-record 404, because history outlives the records it describes (ADR 0018).
  • /api/v1/openapi.json is built from the same Zod schemas the routes validate with, and a test fails unless it matches the routes the app serves (ADR 0007). /api/v1/reference renders it (ADR 0015).

Verification

In a clean checkout of main with only this change applied: bun run check, bun run test (335 tests) and reuse lint pass.

  • Races: concurrency.test.ts forces them against real PostgreSQL 18, which CI now provides; PGlite is a single connection and cannot exercise a lock (ADR 0020).
  • Privileges: privileges.test.ts and documented-setup.test.ts run the product on exactly the two roles docs/deployment.md describes, and bound what the runtime role holds.

ADRs 0003–0007, 0014, 0015 and 0017–0020 land here as written. Links to ADRs 0008–0013 and 0016 resolve as the following PRs land.

@quality-runtime
quality-runtime Bot force-pushed the feat/controls-api branch 6 times, most recently from cd5fb2c to b7160b6 Compare September 18, 2026 22:46
The first part of the requirement -> control -> evidence loop reachable over HTTP, on the database enforcement already on main.

Every tenant-owned route sits under /api/v1/organizations/{organizationId}, behind a middleware that resolves the caller's membership first and binds withOrganization to that organization, so a handler cannot reach another tenant or run without one; a non-member gets 404 rather than 403. Errors share one envelope, bodies and queries are validated with Zod, and collections page by an opaque cursor scoped to the ordering it came from.

Controls can be created, read, listed, changed and moved through their one-way lifecycle, and discarded while they never took effect. Amending and discarding accept If-Match against the row's xmin, parsed to RFC 9110; a change that alters nothing writes nothing, so it cannot stale another client's tag. Every mutation writes an audit event in the same transaction, and a change made while impersonating is attributed to the administrator by id and name. GET /history serves the organization's audit trail, narrowed to one record with ?resource=, and outlives the records it describes.

/api/v1/openapi.json is built from the same Zod schemas the routes validate with, and a test holds it to the routes the app actually serves; /api/v1/reference renders it.

The concurrency suite runs against a real PostgreSQL 18 in CI, because PGlite is a single connection and cannot exercise a lock. Standards and mapping, evidence and attestation, and files arrive in the next changes; their tables are already on main.

Signed-off-by: quality-runtime[bot] <330432719+quality-runtime[bot]@users.noreply.github.com>
@quality-runtime
quality-runtime Bot merged commit 5189787 into main Sep 18, 2026
6 checks passed
@quality-runtime
quality-runtime Bot deleted the feat/controls-api branch September 18, 2026 22:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

0 participants