feat: serve controls and their history over the API - #4
Merged
Merged
Conversation
quality-runtime
Bot
force-pushed
the
feat/controls-api
branch
6 times, most recently
from
September 18, 2026 22:46
cd5fb2c to
b7160b6
Compare
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
Bot
force-pushed
the
feat/controls-api
branch
from
September 18, 2026 22:50
b7160b6 to
ba09e9f
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
/api/v1/organizations/{organizationId}. A middleware resolves the caller'smemberrow first and bindswithOrganizationto that organization, so a handler can neither reach another tenant nor run without one. A non-member gets 404, not 403 (ADR 0004).{ error: { code, message, details } }), Zod validation of bodies and queries, and a 64 KiB body limit decided before anything is parsed.(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).Controls
GET/POST /controlsGET /controls/{id}ETagfrom the row'sxmin.PATCH /controls/{id}draft → active → retired, one way. A request that changes nothing writes nothing, so it cannot stale another client's tag.DELETE /controls/{id}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).History and API description
GET /historyserves 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.jsonis 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/referencerenders it (ADR 0015).Verification
In a clean checkout of
mainwith only this change applied:bun run check,bun run test(335 tests) andreuse lintpass.concurrency.test.tsforces them against real PostgreSQL 18, which CI now provides; PGlite is a single connection and cannot exercise a lock (ADR 0020).privileges.test.tsanddocumented-setup.test.tsrun the product on exactly the two rolesdocs/deployment.mddescribes, 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.