Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ Quality Runtime is being designed as a TypeScript application with these layers:
email, AI, etc.
```

Domain mutation rules still live in route handlers, which use a Hono context to resolve the tenant and attribute changes. Extract them when a non-HTTP caller needs them, into functions taking explicit inputs and actor context; PostgreSQL tenant scoping and audit recording are already available independently of Hono.

Deployment environments sit outside the core application:

```text
Expand Down Expand Up @@ -222,6 +224,8 @@ Applied migrations must not be rewritten.
**AUDIT-01 — Important changes are auditable**
Material quality and compliance state changes must leave durable audit history.

Changes made by a domain mutation request are audited. What a _foreign key_ does is not: a cascade is a referential action, invisible to the application that triggered it, so removing a standard takes its requirements and removing an organization takes everything with no event for any of it. That is a known hole in this invariant rather than a reading of it — `docs/data-model.md` and [ADR 0010](docs/adr/0010-mapping-controls-to-requirements.md) name each place it bites.

**VERSION-01 — Historical state is preserved where required**
Controlled or finalized records must not silently lose historical state.

Expand Down
27 changes: 19 additions & 8 deletions apps/server/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ import { failure } from "./responses.ts";
import { openApiDocument, openApiPath, referencePath } from "./openapi.ts";
import { organizationContext } from "./organization.ts";
import { history } from "./history.ts";
import { requirements } from "./requirements.ts";
import { standards } from "./standards.ts";

/**
* The HTTP surface.
Expand Down Expand Up @@ -42,24 +44,31 @@ export function createApp<Q extends PgQueryResultHKT>({
* Where the rendered reference loads its bundle from, when not the CDN.
*
* Passed in rather than read from `process.env` here: this is core, and core
* must not know how a deployment keeps its configuration (ARCH-01). A Workers
* entry has no `process.env` at all — its environment arrives per request.
* must not know how a deployment keeps its configuration (ARCH-01).
*/
apiReferenceBundleUrl?: string;
}) {
const tenant = "/api/v1/organizations/:organizationId";

/**
* How much of a body a route may read, decided before any of it is read.
* How much of a body each route may read, decided before any of it is read.
*
* A body is read and parsed in full before a validator sees it, so the field
* bounds in a route schema do not bound the work a request costs. Chosen
* here rather than on the route: a limiter with no `Content-Length` to go on
* buffers the stream before passing it down, so a route that needs a
* different allowance has to be told apart here, ahead of this one.
* bounds in a route schema do not bound the work a request costs. One figure
* cannot serve every route: 64 KiB refuses a legitimate standard, and a
* standard's allowance would let every other route accept one.
*
* It has to be chosen here rather than on the route. A limiter with no
* `Content-Length` to go on buffers the stream before passing it down, so a
* generous limit in front of a strict one is simply the generous one.
*/
const tooLarge = (c: Context) =>
c.json(failure("payload_too_large", "The request body is too large."), 413);
const ordinary = bodyLimit({ maxSize: 64 * 1024, onError: tooLarge });
// A standard arrives whole, with the text of every clause it states (ADR 0009).
const standardImport = bodyLimit({ maxSize: 1024 * 1024, onError: tooLarge });
const importsAStandard = (c: Context) =>
c.req.method === "POST" && /^\/api\/v1\/organizations\/[^/]+\/standards$/.test(c.req.path);

return (
new Hono()
Expand Down Expand Up @@ -93,9 +102,11 @@ export function createApp<Q extends PgQueryResultHKT>({
...(apiReferenceBundleUrl ? { cdn: apiReferenceBundleUrl } : {}),
}),
)
.use("/api/v1/*", bodyLimit({ maxSize: 64 * 1024, onError: tooLarge }))
.use("/api/v1/*", (c, next) => (importsAStandard(c) ? standardImport : ordinary)(c, next))
.use(`${tenant}/*`, organizationContext({ auth, db }))
.route(tenant, controls)
.route(tenant, history)
.route(tenant, standards)
.route(tenant, requirements)
);
}
2 changes: 1 addition & 1 deletion apps/server/audit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ type AuditFields = schema.AuditFields;
* out which record an identifier names. One source, so a new entity cannot be
* recordable and unreadable.
*/
export const resourceTypes = ["control"] as const;
export const resourceTypes = ["control", "standard"] as const;

export type ResourceType = (typeof resourceTypes)[number];

Expand Down
5 changes: 2 additions & 3 deletions apps/server/bun.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,8 @@
/**
* Bun entry point: the deployment adapter for a long-lived server process.
*
* Deployment-specific by design — it reads the environment and owns a
* connection pool for the life of the process. A Workers entry would sit
* beside this file and build a per-request client behind Hyperdrive instead
* Reads deployment configuration and owns the connection pool for the life of
* the process; core code depends on neither this entry point nor its environment
* (ARCH-01).
*/

Expand Down
152 changes: 151 additions & 1 deletion apps/server/concurrency.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@
*/

import { fileURLToPath } from "node:url";
import { createDatabase, schema } from "@qualityruntime/db";
import { createDatabase, schema, withOrganization } from "@qualityruntime/db";
import { eq } from "drizzle-orm";
import { drizzle } from "drizzle-orm/node-postgres";
import { migrate } from "drizzle-orm/node-postgres/migrator";
import { Pool, type PoolClient } from "pg";
Expand Down Expand Up @@ -168,6 +169,36 @@ const control = async (name: string) => {
return (await json<{ data: { id: string } }>(response)).data.id;
};

/**
* Two of acme's requirements, importing a standard the first time. Each test
* asks for its own rather than relying on one that ran before it, so a test
* picked out with `-t` still has what it needs.
*/
const requirements = async (): Promise<[string, string]> => {
const held = () =>
admin.query<{ id: string }>(
`select "id" from "requirement" where "organization_id" = $1 order by "id" limit 2`,
[acme.organizationId],
);
let { rows } = await held();
if (rows.length < 2) {
const imported = await request("/standards", {
method: "POST",
body: JSON.stringify({
name: "ISO 9001",
edition: "2015",
requirements: [
{ reference: "7.5.1", title: "General" },
{ reference: "7.5.3", title: "Documented information" },
],
}),
});
expect(imported.status).toBe(201);
({ rows } = await held());
}
return [rows[0]!.id, rows[1]!.id];
};

beforeAll(async () => {
if (!usable) return;

Expand Down Expand Up @@ -351,6 +382,125 @@ describe.skipIf(!usable)("what a lock actually prevents", () => {
expect(replaced).toContain("Original");
expect(new Set(replaced).size).toBe(2);
});

it("refuses a conditional remapping of a control remapped while it waited", async () => {
// The last mutation that was last-writer-wins. A set has no `xmin`, so its
// version is its contents — and the handler already holds `for update` on
// the control while it replaces them, which is what makes comparing the
// contents safe (ADR 0019).
const id = await control("Contested requirements");
const [first, second] = await requirements();

const read = (await request(`/controls/${id}/requirements`)).headers.get("etag")!;

const response = await holding(
`select * from "control" where "id" = $1 for update`,
[id],
async ({ session, blocked }) => {
const mapping = request(`/controls/${id}/requirements`, {
method: "PUT",
body: JSON.stringify({ requirementIds: [first] }),
headers: { "if-match": read },
});
await blocked();
// Somebody else maps it to the other requirement, and commits.
await session.query(
`insert into "control_requirement" ("organization_id", "control_id", "requirement_id")
values ($1, $2, $3)`,
[acme.organizationId, id, second],
);
await session.query("commit");
return mapping;
},
);

expect(response.status).toBe(412);
// And the set the other writer left is untouched.
const { data } = await json<{ data: { id: string }[] }>(
await request(`/controls/${id}/requirements`),
);
expect(data.map((row) => row.id)).toEqual([second]);
});

it("refuses a mapping to a requirement deleted while it waited, rather than failing", async () => {
// What `for key share` on the named requirements is for. Deleting the
// standard takes its requirements by cascade and holds their rows until it
// commits; the replacement waits on that lock and then finds them gone.
// With a plain read it would see them, pass the check, and meet the
// foreign key on insert instead — a 500 for what is a bad request.
const id = await control("Answering to a doomed clause");
const imported = await request("/standards", {
method: "POST",
body: JSON.stringify({
name: "Doomed",
edition: "1",
requirements: [{ reference: "1", title: "Soon gone" }],
}),
});
expect(imported.status).toBe(201);
const standardId = (await json<{ data: { id: string } }>(imported)).data.id;
const { rows } = await admin.query<{ id: string }>(
`select "id" from "requirement" where "standard_id" = $1`,
[standardId],
);
const requirementId = rows[0]!.id;

const response = await holding(
`delete from "standard" where "id" = $1`,
[standardId],
async ({ session, blocked }) => {
const mapping = request(`/controls/${id}/requirements`, {
method: "PUT",
body: JSON.stringify({ requirementIds: [requirementId] }),
});
await blocked();
await session.query("commit");
return mapping;
},
);

expect(response.status).toBe(400);
const { error } = await json<{ error: { details?: { message: string }[] } }>(response);
expect(error.details?.[0]?.message).toContain(requirementId);
});

it.each([
["one snapshot", true, "same"],
["a snapshot per statement", false, "different"],
])("reads a collection and its version from %s", async (_case, repeatableRead, expected) => {
// Two statements in one transaction see two committed states under `read
// committed`, which is right for a write deciding from what is stored now
// and wrong for a read whose answers must agree: a page of a collection and
// the version describing that collection (ADR 0019).
const id = await control(`Snapshot ${expected}`);
const [requirementId] = await requirements();

const mapped = (tx: Parameters<Parameters<typeof withOrganization>[2]>[0]) =>
tx
.select({ requirementId: schema.controlRequirement.requirementId })
.from(schema.controlRequirement)
.where(eq(schema.controlRequirement.controlId, id));

const [before, after] = await withOrganization(
db,
acme.organizationId,
async (tx) => {
const first = await mapped(tx);
// Somebody else maps it, and commits, between the two reads.
await admin.query(
`insert into "control_requirement" ("organization_id", "control_id", "requirement_id")
values ($1, $2, $3)`,
[acme.organizationId, id, requirementId],
);
return [first, await mapped(tx)] as const;
},
{ repeatableRead },
);

expect(before).toEqual([]);
if (expected === "same") expect(after).toEqual(before);
else expect(after).toHaveLength(1);
});
});

describe.skipIf(!usable)("tenants sharing a connection pool", () => {
Expand Down
Loading
Loading