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
2 changes: 2 additions & 0 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,8 @@ Changes made by a domain mutation request are audited. What a _foreign key_ does
**VERSION-01 — Historical state is preserved where required**
Controlled or finalized records must not silently lose historical state.

"Silently" is load-bearing, and the qualifications are deliberate. Attested evidence cannot be changed or removed by the application at all ([ADR 0012](docs/adr/0012-evidence-and-attestation.md)). A record that never claimed anything is not controlled and may be discarded outright — a draft control, for one ([ADR 0017](docs/adr/0017-discarding-a-draft-control.md)). And removing a tenant removes its history, which is why that is an operator's act with a credential the server does not hold ([ADR 0014](docs/adr/0014-the-runtime-role-owns-nothing.md)) rather than something the API offers.

**EXT-01 — Extensions add rather than patch**
Customization prefers explicit composition points over modifications to core implementation.

Expand Down
2 changes: 2 additions & 0 deletions apps/server/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { controls } from "./controls.ts";
import { failure } from "./responses.ts";
import { openApiDocument, openApiPath, referencePath } from "./openapi.ts";
import { organizationContext } from "./organization.ts";
import { evidence } from "./evidence.ts";
import { history } from "./history.ts";
import { requirements } from "./requirements.ts";
import { standards } from "./standards.ts";
Expand Down Expand Up @@ -108,5 +109,6 @@ export function createApp<Q extends PgQueryResultHKT>({
.route(tenant, history)
.route(tenant, standards)
.route(tenant, requirements)
.route(tenant, evidence)
);
}
20 changes: 20 additions & 0 deletions apps/server/audit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -506,6 +506,26 @@ describe("reading an organization's history", () => {
expect(data.every((event) => event.resourceId === control.id)).toBe(true);
});

it("includes control and evidence events in the same history", async () => {
const control = await given(acme, { name: "With evidence" });
const recorded = await app.request(
`/api/v1/organizations/${acme.organizationId}/controls/${control.id}/evidence`,
{
method: "POST",
headers: { cookie: acme.cookie, "content-type": "application/json" },
body: JSON.stringify({ title: "Minutes", occurredAt: "2026-07-01T09:00:00.000Z" }),
},
);
expect(recorded.status).toBe(201);
const evidenceId = (await json<{ data: { id: string } }>(recorded)).data.id;

const { data } = await readHistory(acme);

expect(data.some((event) => event.resourceId === control.id)).toBe(true);
const theirs = data.find((event) => event.resourceId === evidenceId);
expect(theirs?.resourceType).toBe("evidence");
});

it("narrows to one record by the identifier alone", async () => {
const mine = await given(acme, { name: "Mine" });
const other = await given(acme, { name: "Another" });
Expand Down
4 changes: 2 additions & 2 deletions 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", "standard"] as const;
export const resourceTypes = ["control", "evidence", "standard"] as const;

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

Expand All @@ -41,7 +41,7 @@ export type Change = Records &
(
| { action: "created"; before?: never; after: AuditFields }
| { action: "deleted"; before: AuditFields; after?: never }
| { action: "updated"; before?: AuditFields; after: AuditFields }
| { action: "updated" | "attested"; before?: AuditFields; after: AuditFields }
);

/** Who the change is attributed to, resolved once per request. */
Expand Down
2 changes: 1 addition & 1 deletion apps/server/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ export const authOptions = {
// and a foreign key's cascade answers to neither row-level security nor
// table privileges — it would take the audit log and every attestation
// with it. Removing a tenant is an operator's job, not a self-serve
// route an owner can reach (ADR 0005, ADR 0014).
// route an owner can reach (ADR 0005, ADR 0012, ADR 0014).
organization({ disableOrganizationDeletion: true }),
admin(),
twoFactor(),
Expand Down
Loading
Loading