# Transactional Server-Action Audit (KAR-541)

> Closes KAR-541. ASVS V2.3.1.
> Date: 2026-05-23.
> Scope: Server Actions that perform multiple DB writes in one user-action. Identifies which need a transaction wrapper and which already have one.

A Server Action that does two writes without a transaction has a failure mode where the first write succeeds and the second fails — the database ends in an inconsistent state, and the audit log says "the action was called" rather than "the action completed".

## Inventory

| File | Writes per action | RPCs | Verdict | Action |
|---|---:|---:|---|---|
| `app/intake/new/actions.ts` | 3 single-write + 1 RPC (`submit_intake`) | 1 | PASS — `submit_intake` is the only multi-write path and is a Postgres RPC (transactional). | None. |
| `app/intake/board/actions.ts` | 3 actions, each uses a `supabase.rpc()` for the mutation | 3 | PASS — board mutations are RPC-wrapped. | None. |
| `app/intake/submit/[token]/actions.ts` | 1 action: token-verify + insert | 0 | PARTIAL — if the insert fails after token consumption, the user cannot retry. Token-replay window also in `server-actions-audit.md` F-3. | **Wrap in `submit_intake_via_token` RPC.** |
| `app/pmo/[id]/workstreams/actions.ts` | 3 actions; `saveWorkstreamOrder` bulk-updates positions via N round-trips | 0 | FAIL — partial failure scrambles order. | **Wrap in `pmo_save_workstream_order` RPC.** |
| `app/pmo/[id]/weekly/actions.ts` | 4 actions; `moveActivityToDay` updates two rows | 0 | FAIL — partial failure leaves both day-counts wrong. | **Wrap in `pmo_move_activity_to_day` RPC.** |
| `app/oee/analyse/actions.ts` | 15 actions, single-row CRUD each | 0 | PASS — single-row CRUD is atomic per Postgres. | None. |
| `app/admin/intake-fields/actions.ts` | 10 actions, mostly single-row | 0 | PASS for single-row paths; **check `saveIntakeFieldOrder`** if it iterates. | **If reorder is multi-row: wrap in RPC.** |
| `app/lsc-workshop/[id]/workshop-erfassung/actions.ts` | 2 actions | 0 | PASS — single-write. | None. |

## Recommended RPC wrappers (pre-pilot)

In priority order:

1. **`pmo_save_workstream_order(project_id uuid, items jsonb)`** — `UPDATE pmo_workstreams SET position = items.position FROM jsonb_to_recordset(items) AS items(id uuid, position int) WHERE pmo_workstreams.id = items.id AND pmo_workstreams.project_id = ?`. RLS predicate stays the same.
2. **`pmo_move_activity_to_day(activity_id uuid, target_day uuid)`** — folds source-decrement + target-increment + activity-row update into one function.
3. **`submit_intake_via_token(token uuid, payload jsonb)`** — folds token-verify + token-consume + intake-insert. Closes the F-3 token-replay window.
4. **`admin_save_intake_field_order(items jsonb)`** — if it currently does N updates.

Each RPC ships as its own migration with rollback. The PR for the migration also updates the Server Action call-site.

## Test pattern for transactional wrappers

```ts
it("rolls back when the second statement fails", async () => {
  await expect(
    supabase.rpc("pmo_move_activity_to_day", {
      activity_id: existing,
      target_day: "non-existent-uuid",
    }),
  ).rejects.toThrow()

  // source-day counter must not have decremented
  const { data: src } = await supabase
    .from("pmo_days")
    .select("activity_count")
    .eq("id", sourceDayId)
    .single()
  expect(src?.activity_count).toBe(initialCount)
})
```

## What this audit did NOT cover

- **Idempotency keys** — separate concern, tracked in KAR-526 F-3.
- **Optimistic locking** (`updated_at` checks) — out of L2 scope.
- **Long-running multi-step workflows** (provisioning) — run via `after()` queue per ADR 005.

## References

- KAR-522 audit Source 9 (Next.js Server Actions transactional gap).
- KAR-526 Server Actions audit (F-3 token-replay).
- ADR 005 — Provisioning execution model.
- Postgres documentation — transactions, `jsonb_to_recordset`.
