the fix in the previous commit (raw INSERT instead of
tx.auditEvent.create) is a non-obvious consequence of the
append-only-by-role contract — adr-0013's writer/reader role
separation forbids audit_writer from holding SELECT, which prisma's
default `INSERT … RETURNING *` requires. without this note in the
adr, a future contributor will likely reach for prisma's orm
create() again and hit the same misleading "permission denied for
table events" 500 we spent a session debugging.
added:
- an "implementation trap" callout in §"Writer" of the decision
outcome explaining why orm create() can't be used and what we
do instead.
- a cross-reference in the corresponding confirmation entry.
- last-updated: 2026-05-13 in the frontmatter.
no decision changes — the role contract, schema, and writer
behavior are unchanged; this is purely a footgun-warning to
preserve hard-won context.
manual smoke after #120 returned 500 on the first /auth/logout :
PostgresError code 42501 — permission denied for table events
acl looked correct (audit_writer=a/audit_owner), has_*_privilege
returned t everywhere, and a manual psql `INSERT INTO audit.events`
succeeded after SET LOCAL ROLE audit_writer. but the same INSERT
WITH `RETURNING id` failed with the exact same error.
root cause: tx.auditEvent.create() in prisma emits `INSERT …
RETURNING *` to hydrate the entity it returns, and postgres requires
SELECT on every column in RETURNING. audit_writer has INSERT only
per adr-0013's append-only-by-role contract. RETURNING fails with
"permission denied for table X" — and the message says nothing
about SELECT or RETURNING, which made the bug take a long debug
session to isolate.
fix: AuditWriter.recordEvent now uses tx.$executeRawUnsafe with a
parameterised raw INSERT instead of the orm create(). the role
contract stays strict (audit_writer keeps INSERT only — no SELECT,
UPDATE, DELETE, TRUNCATE). the alternative — GRANT SELECT to
audit_writer — would have collapsed the writer / reader role
separation that adr-0013 hinges on, so we went the other way.
bonus: also fixes an env-sensitivity bug in auth.controller.spec.ts.
the test asserting `session.absoluteExpiresAt == createdAt +
43_200 * 1000` was reading the default via readSessionTimeouts() but
didn't override SESSION_ABSOLUTE_TIMEOUT_SECONDS. apps/portal-bff/
.env may carry a custom value (it did during the audit debugging),
making the test fail non-deterministically. now it deletes the env
var before, restores after — same pattern as the other env-touching
tests in this file.
144/144 specs pass under the clean-env repro:
env -u REDIS_URL -u SESSION_SECRET -u SESSION_ENCRYPTION_KEY \
-u SESSION_IDLE_TIMEOUT_SECONDS -u SESSION_ABSOLUTE_TIMEOUT_SECONDS \
-u LOG_USER_ID_SALT -u DATABASE_URL -u ENTRA_* \
pnpm exec nx run-many -t test lint build --projects=portal-bff
notable shape choices:
- gen_random_uuid() server-side instead of prisma's @default(uuid())
client-side. the model still declares @default for future orm reads
by audit_reader; the write path now uses postgres's built-in
(available since 13, target is 17).
- explicit enum + jsonb casts ($2::"audit"."AuditAudience" etc.)
because parameters travel as TEXT on the wire.
- parameterised via $1, $2, …; never interpolated. a spec pins this
with a sql-injection-shaped eventType input.
unchanged: schema, migration, role grants, ADR-0013 contract.