文件
hyb-oa/.trellis/spec/hyb-oa/backend-fastify-prisma.md
T

689 行
28 KiB
Markdown

# Backend Fastify / Prisma
## Scope
Server work lives under `server/`. Prefer existing route, service, utility and
QA script patterns over new abstractions.
## Rules
- Validate Prisma changes with `cd server && npm run prisma:validate`.
- For route/service edits, run `node --check` on the touched files and the
focused regression listed in the current Trellis task.
- Prisma migrations require rollback notes in the Trellis task design or PRD.
- Do not resolve migration drift, reset databases, or rewrite migration history
without explicit approval.
- Workflow, approval and permission behavior must be verified at process level,
not only syntax level.
## Common Checks
- `cd server && node --check src/routes/reimbursements.js`
- `cd server && node --check src/routes/workflow.js`
- `cd server && ./scripts/regression-workflow-actions.sh`
- `cd server && ./scripts/regression-all.sh`
## Scenario: Prisma Client Defaults In Raw SQL
### 1. Scope / Trigger
- Trigger: a script or route uses `$queryRaw`, `pg` or handwritten `INSERT` SQL against a Prisma-managed table whose schema declares `@default(uuid())` or another client-side default.
### 2. Signatures
- Prisma signature: `id String @id @default(uuid()) @db.Uuid`.
- Database signature may still be `id UUID NOT NULL` without a database `DEFAULT`.
- Raw insert must include `id` and bind an explicit UUID, for example `$1::uuid`.
### 3. Contracts
- Prisma schema defaults are not assumed to exist in PostgreSQL; inspect the applied migration or live column default before omitting a raw-SQL field.
- Raw audit inserts generate a fresh UUID in application code and keep the audit insert in the same transaction as the protected write.
- Dry-run may exercise audit inserts, but it must roll the transaction back and report zero committed audit rows.
### 4. Validation & Error Matrix
- Raw insert omits a required client-default field with no DB default -> PostgreSQL not-null failure; transaction rolls back.
- Explicit UUID has invalid syntax -> PostgreSQL cast failure; transaction rolls back.
- Audit insert fails after the business update -> the complete transaction rolls back; no partial rule update is allowed.
### 5. Good/Base/Bad Cases
- Good: explicit UUID, request ID, actor, target and JSON audit payload are bound and tested position by position.
- Base: Prisma Client `create()` may rely on the Prisma default because the client supplies it.
- Bad: infer from `@default(uuid())` that handwritten PostgreSQL may omit `id`.
### 6. Tests Required
- Assert the raw SQL column list contains the required ID and the bound value is a valid UUID.
- Assert every parameter position maps to its intended DB type.
- Assert dry-run begins a serializable transaction and executes `ROLLBACK`; audit failure paths must remain fail-closed.
### 7. Wrong vs Correct
#### Wrong
```sql
INSERT INTO audit_logs (request_id, actor_id, extra)
VALUES ($1, $2::uuid, $3::jsonb);
```
#### Correct
```sql
INSERT INTO audit_logs (id, request_id, actor_id, extra)
VALUES ($1::uuid, $2, $3::uuid, $4::jsonb);
```
## Scenario: Local Regression Migration Drift
### 1. Scope / Trigger
- Trigger: a local or attached E2E fails with Prisma `missing column`,
`column does not exist`, enum missing, or route 500 immediately after a
schema-backed feature was added.
- Applies to `server/prisma/schema.prisma`, `server/prisma/migrations/`,
local regression databases such as `hyb_oa_regression`, and Playwright tests
that use real API/database flows.
### 2. Signatures
- Read-only drift check: `cd server && npx prisma migrate status`.
- Apply committed migrations after explicit confirmation:
`cd server && npx prisma migrate deploy`.
- Local service smoke for attached E2E:
`HOST=127.0.0.1 PORT=3000 JOBS_ENABLED=false SERVE_CLIENT_DIST=true npm run dev`.
- Server entrypoints that call `getConfig()` before `buildApp()` must load
`dotenv/config` before reading config.
### 3. Contracts
- `migrate status` is safe and read-only; it may be run before asking the user.
- `migrate deploy` writes database schema state and requires explicit user
confirmation unless the current task already approved that exact local DB.
- Do not use `prisma migrate reset` for E2E recovery without separate backup
and destructive-action approval.
- Attached local E2E should disable background jobs with `JOBS_ENABLED=false`
unless the test explicitly validates job behavior.
### 4. Validation & Error Matrix
- `migrate status` reports pending migrations -> record the migration names,
inspect whether they are additive/destructive, then ask before applying.
- Missing `JWT_SECRET` during `npm run dev` while `.env` exists -> verify the
entrypoint loads `dotenv/config` before `getConfig()`.
- Missing column after `migrate deploy` -> inspect actual columns and migration
history; do not patch tests to hide schema drift.
- E2E selector failure after schema is fixed -> treat as page/test contract
drift and update the focused E2E assertion, not the database.
### 5. Good/Base/Bad Cases
- Good: run `migrate status`, apply only committed migrations after approval,
seed dev users, then rerun the focused Playwright flow.
- Base: if migrations are already current, skip DB writes and debug the test
failure directly.
- Bad: use `migrate reset` or manually add columns to unblock a test.
- Bad: start the server with an ad hoc `node -r dotenv/config` workaround and
leave `npm run dev` broken.
### 6. Tests Required
- `cd server && npm run prisma:validate`.
- `bash scripts/qa/check-local-migration-drift.sh` when local DB drift was the
cause.
- `cd server && node --check src/index.js` if server bootstrap changed.
- Focused Playwright flow that previously failed.
- Frontend `npm run build` when Playwright or client behavior changed.
### 7. Wrong vs Correct
#### Wrong
```bash
cd server && npx prisma migrate reset
```
#### Correct
```bash
cd server && npx prisma migrate status
# after explicit approval for the local regression database only
cd server && npx prisma migrate deploy
```
## Scenario: Portal Announcement Acknowledgement
### 1. Scope / Trigger
- Trigger: extending `announcements_v1` from read-only announcements into formal employee acknowledgement.
- Applies to `server/prisma/schema.prisma`, `server/src/routes/announcements.js`, `client/src/api/announcements.ts`, employee `/announcements`, and admin `/system/announcements`.
- Use this only for lightweight policy acknowledgement; do not treat it as electronic signature, CA signing, contract signing, or full policy CMS.
### 2. Signatures
- DB: `PortalAnnouncement` may carry `category`, `requires_acknowledgement`, `acknowledgement_statement`, `acknowledgement_due_at`.
- DB: `PortalAnnouncementAcknowledgement` stores one receipt per `(announcement_id, employee_id)` with statement/title/summary snapshots and `actor_id`.
- API: `GET /api/announcements?manage=0` returns visible announcements and the current employee's acknowledgement status.
- API: `GET /api/announcements?manage=1` requires `system.settings` and returns acknowledgement summary plus minimal pending employees.
- API: `POST /api/announcements/:id/acknowledge` accepts `{ "confirmed": true }`.
### 3. Contracts
- Required acknowledgement status values: `not_required`, `pending`, `acknowledged`, `overdue`.
- Employee response must not include other employees' acknowledgement rows or pending lists.
- Admin pending list may include only `id`, `employeeNo`, `name`, and `status`; do not include phone, email, WeCom userid, account identifiers, or sensitive profile fields.
- Acknowledgement is explicit only: opening an announcement or clicking its link must never create a receipt.
- Policy content changes after any acknowledgement require a new announcement version; do not mutate title, summary, category, required flag, or statement in place.
### 4. Validation & Error Matrix
- `announcements_v1` disabled -> `409 VALID_001`.
- policy acknowledgement without statement -> `400 VALID_001`.
- `manage=1` without `system.settings` -> `403 PERM_001`.
- acknowledge invisible/missing announcement -> `404 ANN_404`.
- acknowledge normal announcement -> `409 VALID_001`.
- acknowledge without `{ confirmed: true }` -> `400 VALID_001`.
- edit protected policy fields after acknowledgement exists -> `409 VALID_001`.
### 5. Good/Base/Bad Cases
- Good: admin creates a policy announcement, employee checks the confirmation box, server creates one receipt and one audit log.
- Base: normal announcement remains compatible and returns `not_required`.
- Bad: frontend displays another employee's receipt status on `/announcements`.
- Bad: admin updates an acknowledged policy title instead of creating a new version.
### 6. Tests Required
- Prisma validate after schema/migration changes.
- Route focused tests for required statement, permission, idempotency, normal/invisible announcement rejection, admin summary, and protected edit rejection.
- Frontend typecheck and build.
- Playwright flow for admin create -> employee acknowledge -> admin summary update when UI changes.
### 7. Wrong vs Correct
#### Wrong
```js
await prisma.portalAnnouncement.update({
where: { id },
data: { title: "制度 V2" },
});
```
#### Correct
```js
const acknowledgementCount = await prisma.portalAnnouncementAcknowledgement.count({
where: { announcement_id: id },
});
if (acknowledgementCount > 0) {
throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 409 });
}
```
## Scenario: Safe Development Seed
### 1. Scope / Trigger
- Trigger: changing or running `server/src/scripts/seed-dev.js`, its safety
guard, development account defaults or the `seed:dev` npm command.
### 2. Signatures
- Command: `SEED_DEV_ALLOW=1 SEED_DEV_DATABASE_CONFIRM=<db> npm run seed:dev`.
- Optional reset: `SEED_DEV_RESET_PASSWORDS=1`.
- Target: `DATABASE_URL` parsed with the same `pg` semantics used by Prisma's
PostgreSQL adapter.
### 3. Contracts
- `NODE_ENV=production` is permanently refused.
- The explicit allow switch and exact database-name confirmation are both
required.
- The effective PostgreSQL host, including query-string overrides, must be
loopback; checking only the URI authority is insufficient.
- Existing passwords are preserved by default. Newly created accounts and
explicitly rotated accounts set `must_change_password=true`.
- The guard and seed logs never print credentials, complete targets or account
passwords.
### 4. Validation & Error Matrix
- Production -> `production_forbidden`.
- Missing allow switch -> `explicit_allow_required`.
- Invalid/non-PostgreSQL URL -> `database_url_invalid` or
`database_protocol_invalid`.
- Effective non-loopback host, including `?host=...` ->
`database_host_not_local`.
- Missing/mismatched confirmation -> `database_confirmation_mismatch`.
### 5. Good/Base/Bad Cases
- Good: loopback test database, exact confirmation, new accounts require a
password change.
- Base: rerun without reset preserves existing password hashes.
- Bad: accept `postgresql://[email protected]/db?host=remote.internal` because only
`URL.hostname` was checked.
### 6. Tests Required
- Pure guard tests for production, missing allow, remote authority, host-query
override, exact confirmation and redacted errors.
- Unit tests for existing-password preservation, explicit rotation and new
account first-login change.
- Do not validate refusal paths by connecting to a real database.
### 7. Wrong vs Correct
#### Wrong
```js
if (new URL(env.DATABASE_URL).hostname === "127.0.0.1") allowSeed();
```
#### Correct
```js
const client = new Client({ connectionString: env.DATABASE_URL });
if (!LOOPBACK_HOSTS.has(client.host)) refuse("database_host_not_local");
```
## Scenario: Database-Free App Contract Tests
### 1. Scope / Trigger
- Trigger: unit tests call the production `buildApp()` factory to verify health,
readiness, static serving or other app-level contracts in CI without a DB.
### 2. Signatures
- Production: `buildApp()`.
- Test seam: `buildApp({ prismaPlugin, startJobs })`.
### 3. Contracts
- Omitted dependencies always use the production Prisma plugin and job starter.
- Unit tests inject a `fastify-plugin`-wrapped Prisma decorator and a no-op job
starter; they must not rely on ESM `vi.mock` intercepting a CJS `require`.
- App-level test doubles expose only methods used by the tested requests.
- `DATABASE_URL='' JWT_SECRET=''` must not make isolated app tests access a
developer `.env` or local PostgreSQL instance.
### 4. Validation & Error Matrix
- Production call with missing DB target -> normal startup refusal.
- Test call with injected Prisma plugin -> no production client construction.
- Test double missing a method exercised by the request -> focused unit failure.
- Injected dependencies leaking into `buildApp()` callers without options ->
contract regression.
### 5. Good/Base/Bad Cases
- Good: health/static tests inject Prisma and pass with empty DB/JWT process
variables after setting their own test JWT.
- Base: API/Worker call `buildApp()` without options and retain production wiring.
- Bad: a hoisted `vi.mock("../prismaClient")` appears green only because a local
`.env` database is reachable.
### 6. Tests Required
- Run health and static tests with `DATABASE_URL='' JWT_SECRET=''`.
- Run the full `scripts/check-minimal.sh` under the same empty environment.
- Keep the production default path covered by normal startup/smoke checks.
### 7. Wrong vs Correct
#### Wrong
```js
vi.mock("../../src/prismaClient", () => ({ createPrismaClient: fake }));
await buildApp(); // CJS plugin may still construct the real client
```
#### Correct
```js
await buildApp({ prismaPlugin: testPrismaPlugin, startJobs: noOpStartJobs });
```
## Scenario: Authentication Challenge Serialization
### 1. Scope / Trigger
- Trigger: changing password reset, email-code login, refresh rotation,
password login session creation, user password change or administrator reset.
- Applies when a credential event can consume a challenge, change
`token_version`, create/revoke a refresh session or invalidate another
challenge.
### 2. Signatures
- Row barrier: `SELECT ... FROM employees WHERE ... FOR UPDATE`.
- Challenge rows: `password_reset_codes` and `email_login_codes`.
- Session rows: `auth_sessions`.
- Security epoch: `employees.token_version`.
### 3. Contracts
- Use one lock order for credential mutations: Employee first, then challenge
and session rows. Refresh rotation must lock Employee and re-read the session
inside the same transaction before rotating it.
- Capture expiry, cooldown and lockout time after the Employee lock is acquired;
a request queued behind another credential event must not use stale time.
- Creating a new challenge invalidates the previous unused challenge while the
Employee lock is held. An active challenge with a wrong code increments the
account-level failure state; no active challenge returns the generic failure
without penalizing concurrent correct-request losers.
- Successful reset, user password change and administrator reset increment
`token_version`, revoke active refresh sessions and invalidate unused
challenges in the same transaction as the password change and audit event.
- Pepper-backed hashes use distinct purpose domains for email login and
password reset. Production rejects a missing, placeholder, weak or
JWT-reused pepper.
### 4. Validation & Error Matrix
- Unknown/inactive/locked account or missing/expired/consumed challenge ->
stable generic response; no account enumeration detail.
- Wrong code for the current active challenge -> increment account and
challenge attempts; reaching the bound locks and invalidates the challenge.
- Reset commits before login/refresh -> the queued request rechecks state and
cannot create a usable session.
- Login/refresh commits before reset -> reset observes and revokes the newly
created session before it commits.
- Concurrent consumers of one valid challenge -> exactly one successful state
transition; losers do not create audits, sessions or a recovery lock.
### 5. Good/Base/Bad Cases
- Good: every credential path uses Employee -> challenge/session ordering and
is covered by a deterministic PostgreSQL lock race.
- Base: sequential invalid-code attempts use the same account-level counters
and return the same external error contract.
- Bad: refresh locks a session before Employee while reset locks Employee
before sessions; this can leave a newly inserted session outside reset's
statement snapshot.
- Bad: treat `!challenge` after another request consumed the code as a new
failed guess and lock the legitimate account.
### 6. Tests Required
- Focused route tests for CSPRNG/HMAC domain separation, cooldown, account
lockout, missing-challenge losers, conditional consumption and session
revocation.
- Real isolated PostgreSQL checks for concurrent challenge requests, 20-way
same-code consumption, reset-first/login-first ordering and refresh/reset.
- After reset, assert the pre-reset access token and refresh cookie both fail,
the active-session count is zero and only one success audit exists.
- Prisma validate plus clean migration deployment from an empty database.
### 7. Wrong vs Correct
#### Wrong
```js
await tx.authSession.updateMany({ where: { id: sessionId, revoked_at: null } });
await tx.authSession.create({ data: replacement });
```
#### Correct
```js
await lockEmployeeForUpdate(tx, employeeId);
const currentSession = await tx.authSession.findUnique({ where: { id: sessionId } });
if (!currentSession || currentSession.revoked_at) return invalidRefresh();
await rotateSession(tx, currentSession);
```
## Scenario: Small-Team Payment Batch Consistency
### 1. Scope / Trigger
- Trigger: changing payment-batch creation, review, execution, retry,
cancellation, receipt evidence or the legacy direct-payment path.
### 2. Signatures
- Candidate API: `GET /api/payment-batches/candidates?q=&page=&pageSize=`.
- State APIs: `POST /api/payment-batches/:id/review`,
`POST /api/payment-batches/:id/execute`,
`POST /api/payment-batches/:id/cancel` with `{ reason }`.
- Receipt invalidation accepts `{ reason }` and retains the record.
- DB: `payment_batches.cancelled_reason TEXT NULL`.
- Isolated concurrency gate:
`bash scripts/qa/run-web-gates.sh payment-concurrency-local`.
### 3. Contracts
- Normal flow is finance maker/executor plus one independent `manager|ceo`
reviewer; the maker cannot review their own batch.
- Lock the batch before state checks, then lock source entities in stable order
and recheck status, amount and reservation inside the same transaction.
- When `payment_batch_v1` is enabled, an approved reimbursement can enter only
one active batch and the legacy direct-payment route must not form a second
write path.
- Retry processes only `pending|failed` items. Existing `executed` items never
repeat payment writes, follow-ups or notifications. Persisted execution
summary is recomputed from every final item state; the audit separately
records the current attempt.
- Cancellation requires a 2-500 character reason. Receipt evidence is voided
with audit history, not physically deleted.
### 4. Validation & Error Matrix
- Missing review, maker-reviewer conflict or terminal state -> `409 VALID_001`.
- Concurrent loser after a valid winner -> deterministic `409`, zero duplicate
state, audit or notification writes.
- Missing/invalid cancellation or void reason -> `400 VALID_001`.
- Source already paid, amount changed or reserved by another active batch ->
`409 VALID_001` and no partial batch mutation.
### 5. Good/Base/Bad Cases
- Good: one executor wins, a concurrent cancel or second executor receives
`409`, and the final payment/audit/notification counts remain one.
- Base: a failed batch retries only failed items and reaches `executed` with an
all-item final summary.
- Bad: count only the retry attempt in `execution_summary_json`, or allow the
direct route to pay while the same source is in a valid batch.
### 6. Tests Required
- Focused route tests for candidate authority, maker-checker, retry summary,
cancellation, receipt void and zero-side-effect refusals.
- Real disposable PostgreSQL races for double execute, execute/cancel, double
create and direct-payment/batch competition; assert unique audits and
notifications and zero residual test databases.
- Prisma validate, clean empty-database migration application and a read-only
reviewer after payment-state changes.
### 7. Wrong vs Correct
#### Wrong
```js
summary.successCount = successesInThisRetry;
batch.status = failuresInThisRetry ? "failed" : "executed";
```
#### Correct
```js
const finalSummary = summarizeFinalItemStatuses(finalItemStatuses);
batch.execution_summary_json = finalSummary;
batch.status = finalSummary.failedCount > 0 ? "failed" : "executed";
```
## Scenario: PostgreSQL Advisory-Lock Namespaces
### 1. Scope / Trigger
- Trigger: allocating an application-level sequence or serializing a small
cross-row critical section that cannot be protected by locking one existing
row.
- Use this only for PostgreSQL-backed paths whose correctness is also verified
against a disposable real database; do not introduce advisory locks for
ordinary single-row updates already covered by `FOR UPDATE`.
### 2. Signatures
- Shared registry: `server/src/constants/advisoryLocks.js`.
- Lock shape: `pg_advisory_xact_lock(namespace_int4, object_key_int4)`.
- Registered namespace: `paymentBatchDailySequence = 1001`; its object key is
the company-local `YYYYMMDD` integer.
- Prisma call for a void-returning lock statement: `tx.$executeRaw`.
### 3. Contracts
- Register every new first-argument namespace in the shared constant file;
never reuse an existing namespace for an unrelated critical section.
- Use the two-`int4` form with an explicit, inspectable object key. Do not use
`hashtext` or an undocumented magic pair at the call site.
- Acquire the transaction-level lock before reading the protected maximum or
current state. Lock acquisition, protected read and write must stay in the
same Prisma interactive transaction so commit or rollback releases the lock.
- Keep one stable lock order when a transaction needs more than one lock.
- A PostgreSQL uniqueness error aborts the transaction. Do not catch `P2002`
and retry inside the same interactive transaction; any fallback must retry
the complete transaction from the beginning.
### 4. Validation & Error Matrix
- `$queryRaw` reads `SELECT pg_advisory_xact_lock(...)` -> Prisma driver may
fail with `P2010` because PostgreSQL returns a `void` column.
- `$executeRaw` executes the same `SELECT` -> lock is acquired without result
deserialization; continue to the protected query.
- Namespace or object key outside signed `int4` -> fail before business writes;
correct the registry/key design rather than silently casting or hashing.
- Protected daily sequence exhausted -> stable `409 VALID_001` and rollback;
do not widen or randomize the public identifier format at the boundary.
- Concurrent callers with the same pair -> one waits, then reads the committed
maximum and allocates the next value.
### 5. Good/Base/Bad Cases
- Good: two simultaneous payment-batch creations lock `(1001, YYYYMMDD)`, then
commit distinct consecutive identifiers.
- Base: different company-local dates use different object keys and do not
contend.
- Bad: query the maximum before taking the lock, use a process mutex, or retry a
unique violation inside the already-aborted transaction.
### 6. Tests Required
- Unit test the exact call order and error boundary with mocked
`$executeRaw`/`$queryRaw`; assert no create, item or audit write on exhaustion.
- Run `bash scripts/qa/run-web-gates.sh payment-concurrency-local` against a
disposable PostgreSQL database. The daily-sequence race must create two
different sources concurrently and assert two consecutive unique numbers.
- Run `node --check` on the route, namespace registry and concurrency script,
plus Prisma validate and a read-only review for affected payment paths.
### 7. Wrong vs Correct
#### Wrong
```js
await tx.$queryRaw`SELECT pg_advisory_xact_lock(${namespace}, ${dateKey})`;
const latest = await readLatestSequence(tx);
```
#### Correct
```js
await tx.$executeRaw`
SELECT pg_advisory_xact_lock(${namespace}::int, ${dateKey}::int)
`;
const latest = await readLatestSequence(tx);
```
## Scenario: Terminal Contract State and Required Audit
### 1. Scope / Trigger
- Trigger: adding a terminal state or irreversible action to a Prisma-backed
business ledger, especially when the state needs explanatory fields and an
audit trail.
- Applies to `ContractRegistry` termination, archive corrections, all import
or batch writers, and any future endpoint that can mutate the same row.
### 2. Signatures
- `POST /api/contracts-registry/:id/terminate` with `{ reason }` where the
trimmed reason is 10–1000 characters.
- `POST /api/contracts-registry/:id/corrections` with `{ summary }` for an
archived row only.
- DB fields: `status`, `terminated_at`, `terminated_reason`,
`terminated_by`; the employee foreign key uses `ON DELETE RESTRICT`.
- Required audit helper: `writeRequiredAudit(tx, request, action, targetId,
extra)`; it must throw on insert failure.
### 3. Contracts
- `status = terminated` iff all three `terminated_*` fields are non-null and
non-empty; every other persisted status iff all three are null.
- Only the dedicated termination transaction may create `terminated`; general
POST/PATCH and CSV/import writers reject it. Existing terminated rows are
immutable to import updates, and existing archived rows are immutable to
import updates (use the correction API).
- Termination, status changes, renewal and correction audit writes stay inside
the same transaction as the protected write. A correction claims the current
`status + updated_at` version before appending its audit record.
- List DTOs use a minimal mapper; terminal reason, operator and timestamp are
detail-only fields.
### 4. Validation & Error Matrix
- Partial termination fields or a terminal row with missing fields -> stable
`500 SYS_001`; do not serialize an inconsistent record.
- General write with `terminated`, terminal edit/renew/archive, or a stale
correction version -> `409 VALID_001` and zero business/audit side effects.
- Required audit insert failure -> transaction rollback and `5xx`; never use a
swallowing best-effort helper for an irreversible/status-changing operation.
- Import status `terminated`/`expired`, a status transition, or update of an
existing terminated/archived row -> recorded validation error; no upsert.
### 5. Good/Base/Bad Cases
- Good: termination writes status, three fields and audit atomically; a failed
audit leaves the source row active and the audit table unchanged.
- Base: an archived correction updates only the version timestamp and appends
a safe audit summary; a concurrent restore loses the optimistic guard and
returns `409`.
- Bad: write status first, call a best-effort audit afterward, or let a CSV
upsert set `terminated` without the three fields and dedicated audit.
### 6. Tests Required
- Focused route tests for complete/partial invariants, terminal filters and
effective status, general-write rejection, required-audit rollback,
renewal rollback, correction version conflicts, and safe list/detail DTOs.
- Unit test every non-HTTP import status guard and existing-terminal refusal.
- Prisma validate, migration status, `node --check` on touched scripts/routes,
focused Vitest, and Playwright coverage for desktop/mobile action boundaries.
- Read-only reviewer must inspect all write paths, including scripts and
exports, after implementation.
### 7. Wrong vs Correct
#### Wrong
```js
await prisma.contractRegistry.update({ where: { id }, data: { status: "terminated" } });
await writeAudit(prisma, request, "CONTRACT_REGISTRY_TERMINATED", id); // swallows errors
```
#### Correct
```js
await prisma.$transaction(async (tx) => {
const current = await readAndValidateContract(tx, id);
await tx.contractRegistry.updateMany({
where: { id, status: current.status, updated_at: current.updated_at },
data: buildTerminalFields(request, reason),
});
await writeRequiredAudit(tx, request, "CONTRACT_REGISTRY_TERMINATED", id, { reason });
});
```