92 KiB
Backend Fastify / Prisma
Shared Budget Pool Write Transactions
- Entity row locks do not serialize different reimbursements, purchases or card entries sharing a budget pool. Before an authoritative budget decision or usage mutation, call
lockBudgetPoolsForUpdate(tx, poolIds)on the interactive transaction client. It parameterizes UUIDs, deduplicates/sorts all required IDs and usesORDER BY id FOR NO KEY UPDATE; do not substitute process-local locks or absolute writes based on unlocked reads. Prisma 7 supports nested$transaction; use the root-only$connectcapability to reject root clients, not presence of$transaction. - Maintain caller entity/workflow → budget pool lock order; the helper must never acquire entity locks. Card refunds that later synchronize an authorization must lock that authorization before taking a pool lock, matching authorization imports. For multi-pool releases, lock the complete pool set in stable order, then reread the ledger; reject a newly appearing pool instead of acquiring it out of order.
resolveApplicableBudgetPoolByContext(tx, context, { forUpdate: true, additionalPoolIds })selects, locks and rereads the candidate. A different/missing candidate after waiting returns 409 withbudget.pool_changed; previews omitforUpdate. Submission passes the locked record (or explicit null) asbudgetPoolForWritetoresolveEntityPrecheck, preserving the single reimbursement entity read and keeping the checked/charged pool identical. Undefined means ordinary preview resolution; never accept this internal argument from a public request or cache it across transactions.- Pool usage, applicable blocking decisions and ledger changes remain in the same business transaction. Purchase submission must recompute and recheck inside that transaction, not reuse a pre-transaction preview. Preserve control-mode/feature gates, Decimal comparisons at exact capacity, duplicate behavior, release floor and existing audit atomicity. Atomic increments alone do not repair stale over-budget decisions.
- Verify with
RUN_ISOLATED_BUDGET_DB_TEST=1 node scripts/qa/test-budget-pool-db.cjs: real services/routes, synthetic authorized identities and isolated PostgreSQL, observed lock waits, mixed writers, multi-pool order, metadata changes while waiting and fault rollback. These fixtures do not prove production balances, authentication, frozen travel availability or publication eligibility; ordinary full-gate and independent-review obligations remain.
Composing Visibility And Activity Filters
GET /delegation-grantskeeps an ordinary caller within records where they are the principal or delegate. The existingsystem.userscheck alone allows manager-selected principal/delegate filters; do not broaden this tosystem.settingsor change the separate legacy revoke API.- Visibility and activity each contain
OR. Compose them asAND: [baseWhere, activeWhere]; object spread would overwrite the firstORand expose unrelated active grants.includeInactive=trueremoves only activity constraints, retainingbaseWhere. - Preserve
revoked_at=null,valid_from<=now, and(valid_to=null OR valid_to>now). A grant ending exactly now is inactive; one starting exactly now is eligible. Keep user-supplied personnel filters from widening an ordinary caller's scope. - Test actual route query consumption with synthetic own/other, active/future/expired/revoked grants, both includeInactive modes, and administrator explicit filters. An unconditional mocked
findManyresult cannot detect a dropped visibility predicate. In-memory evaluation is not evidence of production data, PostgreSQL behavior or authentication.
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 --checkon 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
Calendar Date Inputs
-
For reimbursement, payment-batch, travel-request, asset, contract-registry, purchase delivery, travel payment/reconciliation, weekly-report and presence date-only input schemas, combine the YYYY-MM-DD shape with isValidDateOnlyString. Reject calendar overflow before persistence; a regex alone accepts February 30 and JavaScript normalizes it.
-
Keep optional and nullable wrappers outside the string validator. Validate leap-day, month-end, invalid month and empty/null boundaries without changing stored historical dates.
-
parseDateOnlyString retains its legacy conversion contract for unaudited consumers; it is not validation. Do not claim the shared parser or other domains are all hardened.
-
Validate weekly-report weekStart before Monday normalization, including the GET that may create a draft. Invalid dates must not create/update a report.
-
Contract expiry filters share contractRegistryFilters with exports. Presence history exports validate their own dateFrom/dateTo schema; do not silently broaden unrelated export filters. Keep the original 409 for a reversed travel reconciliation period after both endpoints pass calendar validation.
-
Todo create/patch dueDate, employee-directory hireDateFrom/To (users and employees routes), holiday-calendar day replacement and the audited employee, dashboard and AP-operation export date-only fields also use the strict predicate. Preserve todo null clearing versus omitted-date retention and original permissions.
-
Validate every holiday item before entering the replacement transaction: one invalid date must not delete existing days or write the successful audit. Duplicate dates retain 409; an empty item list still intentionally clears the calendar.
-
Audit-export timestamps and payroll months are separate contracts. Adding calendar checks does not change AP ledger reversed-range behavior or authorize historical data edits. Exercise both CSV and XLSX rejection before reads/audit and valid UTC bounds.
-
User create/patch and CSV/XLSX hireDate, employee profile/lifecycle/archive dates, AP ledger queries and dashboard queries also use the strict predicate. Validate before business reads/writes; preserve feature/permission ordering and original range responses. Import apply rejects the whole invalid batch (409), while dry-run still records its original preview audit.
-
Read user CSV cells as text before normalization. ExcelJS automatic date coercion uses local time; converting that Date to UTC can move a valid hireDate to the preceding day. CSV and XLSX tests must assert exact date values, blank defaults, preserved textual identifiers and mixed-batch rejection, not only HTTP success.
-
Payroll profile effectiveDate, budget creation periods, performance cycle create/patch dates, attendance correction workDate, attendance sync request ranges and leave create/edit/preview/list dates follow the same input contract. Keep payroll YYYY-MM and timestamp/session semantics separate. Legacy attendance_v1 is permanently disabled: its 409 guard remains authoritative; never enable the gate just to exercise a schema in tests.
HR Import Calendar Contract
- Scope / trigger:
integrations/wecomHrMasternormalizes external hire dates before department/account mutations. Do not let malformed supplied dates use the missing-date default. - Signatures:
normalizeWecomHrProfile({contact, staff, departmentMap})returns canonicalhireDateplus booleanhireDateInvalid;runWecomHrMasterSync({prisma, config, mode, sourceProfiles, fetchImpl, ...})rechecks normalized offline input. - Contract: separated/compact calendar dates and positive seconds/milliseconds remain supported. Invalid canonical or expanded-year dates are rejected. Missing dates retain the run's
now; existing employee updates still omithire_date. No new env, DB or public request field is introduced. - Validation/error matrix: invalid date → item
action=skip,status=skipped,errorCode=invalid_hire_date, fixed safe message; no department, employee, role, identity or manager access. Run/item audit is retained. Missing required fields and inactive-source guards keep their existing priority. - Good/base/bad:
2024/2/29→2024-02-29; blank → original create default;2026-02-29/20260230→ skip, never February overflow or default today. Valid items in a mixed batch continue normally. - Tests:
wecomHrMaster.calendar.test.jsexercises the actual run service with mock DB and rejecting fetch; assert exact UTC dates, preserved existing hire date, audit-only skips, dry-run/apply and mixed-batch behavior. This is not real WeCom/PG evidence. - Wrong/correct:
hireDate = parse(raw) || nullalone loses invalidity. PreservehireDateInvalidand guard it beforeresolveDepartment; independently validate any directly supplied normalizedhireDate.
Shared Read Filter Error Contract
- Scope / trigger: employee directory, contract-registry filters and presence-history ranges, including their export consumers.
- Signatures:
parseEmployeeDirectoryFilters,parseContractRegistryFilters,parseContractRegistryExpiringQuery,resolvePresenceHistoryRange; exportparseQuery(schema, query, reasonCode?)/parseBody(schema, body, reasonCode?)accept an explicit optional registered reason. - Contract: retain HTTP 400,
VALID_001, original flatten/field details and guard order; add onlydetails.reasonCode. Shared legacy helpers used by other write/conflict branches keep their contract. - Validation/error matrix: employee schema/reversed hire range →
employee_directory.query_invalid/hire_date_range_invalid; contract schema/reversed expiry/days →contract_registry.query_invalid/expiry_date_range_invalid/expiring_query_invalid; history reversed/>31 days →presence_history.date_range_invalid/date_range_too_large. - Good/base/bad: 31 inclusive history days valid; absent history endpoints preserve 7-day default; contract expiring days 1–365 valid, 0/366 invalid. Employee/contract export outer schemas use their domain query reason, including invalid CSV/XLSX format.
- Tests: actual Fastify negative requests assert exact reasons and legacy details before business queries/success audit; CSV/XLSX and unaffected export paths regress together. Registry/client/fixed message fixture must agree.
- Wrong/correct: migrating only the shared filter misses outer export validation. Pass its domain reason explicitly; never infer it from ambiguous detail keys. Dynamic helper reasons must use registry validation (
getErrorReason(reasonCode).code); only remove disappeared AST baseline entries, not still-used legacy helpers.
Import Files, Announcement Times And Payroll Errors
- Scope / trigger:
usersCSV/XLSX import; announcement create/patch/acknowledge; payroll maintenance and month/export queries. These errors retain status/details and business guard order. - Signatures:
readUserImportPayload(request)must catchFST_REQ_FILE_TOO_LARGEboth atrequest.file()andfilePart.toBuffer(). Announcement/payrollparseBody(schema, body, reasonCode)take the operation's explicit reason; generic exports/attendance parsers retain optional reason parameters for unmigrated callers. - Contracts: import oversize remains production HTTP 400/VALID_001 with
file=file_too_large, now addinguser.import_file_too_large; leave other read exceptions unchanged. CSV/XLSX parsing follows the narrow boundary below. Preserve the 500-row cap and original preview audit. Announcement DATE_TIME_SCHEMA retains its old ISO/date-T-prefix alternatives, then requires a real calendar date prefix and finite parsed Date; nullable/optional, local/offset interpretation and valid 24:00 rollover remain compatible. - Validation/error matrix: seven file failures →
user.import_*; announcement input/id/query/disabled/statement/ack guards →announcement.*; payroll month/item/profile/payslip conflicts →payroll.*; unified attendance month errors →attendance.month_*. Keep original 400/409 and nested amount-index/missing-profile details; fixed client text must never interpolate raw file content, staff lists, or amounts. - Good/base/bad: 500 import rows may preview; 501 rejects before business access. Announcement
2024-02-29T23:30:00-12:00is valid even though UTC is March 1; February 30 or an unparseable T suffix must reject before reads/writes. Payroll/attendance month 00/13 already fail in downstream parsers: preserve that behavior instead of adding contradictory schema rules. Low years 0000–0099 still have legacy Date.UTC mapping and require a separate accepted-year/DB compatibility decision. - Tests required: actual multipart dry-run/apply and limit rejection; announcement create/patch across all three dates and signature guards; payroll salary item/profile/generate/edit/issue and P2002 simulation with no successful audit or subsequent mutation. Preserve originals/permission checks and assert exact reason plus legacy fields. P2002 mocks do not prove PostgreSQL uniqueness/rollback. Run bcrypt-heavy import regressions without concurrent frontend builds; do not remove hashing or business assertions to meet a timeout.
- Wrong/correct: catching only
request.file()misses stream-size errors fromtoBuffer(). Route-local catch both using the same registered reason. Replacing a domain builder globally can mislabel input vs state conflicts: pass explicit reasons per operation, validate dynamic codes through the registry, and delete a legacy helper only after all its callers migrate. Announcement mutation toasts consumeresolveErrorMessage(error, fallback)so page callbacks do not erase the registered explanation; no new visual design is needed.
User Import Parser Failures
- Catch only the in-memory ExcelJS
csv.readandxlsx.loadawaits. Known format failures useuser.import_file_invalid, HTTP 400 / VALID_001, with only fixedfile=invalid_formatplus reasonCode; no filename, cell, sheet, parser message or cause. - The installed fast-csv/JSZip/saxes versions expose plain Error messages rather than codes. Keep format-specific, anchored message allowlists supported by real malformed-file tests. Different error types, error codes/statuses and unknown messages remain 500; replace parser exceptions with a fresh fixed internal Error so the global logger receives no original message, stack, cause or custom properties. Do not map every exception to invalid input.
- Keep
request.file,toBuffer, worksheet normalization, permission, DB, transaction and hashing outside that catch. Apply's preview reads already happen inside its transaction; do not claim all internal failures occur before transaction entry. Format rejection itself must precede all preview/transaction/business access. - Test both multipart endpoints with actual malformed CSV, ZIP and XML and the production error-handler callback; verify zero business access and no raw exception logging. Regress valid quoted multiline CSV, XLSX, exact date text/defaults, 500/501 rows, original file guards and real hashing. Unknown failures and parser-like messages thrown by reads/DB/hash must retain their separate classifications. Use only synthetic input; mock transactions do not prove PostgreSQL rollback.
Payment Account And Invoice Read Errors
- Payment-account maintenance and invoice-pool read validation use registered
payment_account.*andinvoice_pool.*reasons. Keep HTTP status, top-level error code and legacy detail fields; addreasonCoderather than changing permissions, queries or transaction order to make validation pass. - Invalid input and unavailable references must fail before successful writes, audit or notifications. Retain masked-account behavior and never echo input bank accounts in error messages. Reason registry, client mapping and exact message fixtures change together; remove only the migrated AST baseline entries.
Receipt OCR Matching Service Boundary
-
Ordinary bank-payment evidence must retain a versioned
documentclassification (bank_receipt,non_final,unknown) in the encrypted recognition/proposal. Bound local PDF inspection by input size, page count, worker memory and time; unreadable text remains unknown. Preserve provider disclaimers as restrictions. A title, matching fields, or absence of an extracted disclaimer is not sufficient. Automatic matching needs a supported final receipt, explicit successful payment and no incoming direction or conflicting document facts. Keep this gate in evidence construction and final reconstruction, not the sharedmatchBankSliphelper used by failed/voided/terminal-fact comparisons. -
Manual
paymentProofmust attest a checked original and record the basis/note.booked_debitrequires a valid non-future bank booking date and an empty original success status; preservetransactionStatusRaw=nullrather than fabricate bank wording. New proof always needs the batch's applicable confirmation mode. Non-final disclaimers, incoming direction and known contradictory facts cannot be removed by manual corrections. Manual-source evidence is not automatic OCR. Browser fixtures entering synthetic receipt facts manually must supply the checked-original proof supported by that fixture's actual contents; bounded parsing may legitimately stay unknown. Report only safe document categories and allowlisted business reason codes on failure, never raw banking fields. -
Old unconsumed evidence without document inspection cannot default to final. Finance may explicitly POST
receipt-attachments/:attachmentId/document-checkwithexpectedVersion; use the existing batch/claim/attachment locks, scope and maker checks. Verify stored bytes against size/SHA, create a new version and audit atomically, preserve history and never call cloud OCR. Old manual approval does not transfer to the new version. Reinspect and settle under the same batch lock; runtime tests must cover both orders, stale versions and audit rollback. A historical OCR result with zero known pages must still expose the inspection action; reject malformed/out-of-range counts without allocating from them. -
Failed evidence persistence is not OCR failure. In a separate bounded transaction, lock the batch then conditionally update the attachment by version/SHA/processing and active governance predicates. Persist only allowlisted system_failed, key_failed or snapshot_failed states; never an exception message or OCR payload. A failed fallback write returns failure_recording_failed, not a claimed durable failure. Do not automatically retry cloud calls or overwrite a newer job/void.
-
System failure states do not permit ordinary OCR retry or manual proposals. Administrator recovery requires fixing the prerequisite and a separately authorized version-bound requeue; no bulk reset or silent cloud retransmission.
-
Bank reimbursement execute calls claimVerifiedReceipts inside the existing payment transaction after batch/source locks. Receipt existence alone never permits registration. Existing non-bank and travel-history paths are not silently converted into the reimbursement BankSlipOCR contract.
-
Claims reference the exact approved manual proposal. A unique transaction-alias table reserves both original and corrected identities against one claim. Original-reference reuse, concurrent manual/automatic claims and changed current manual-reviewer roles/participants fail closed; aliases and payment roll back together. Keep the migration's existing-claim alias backfill.
-
GET receipt-readiness uses RepeatableRead READ ONLY with the explicit readOnly service mode: no FOR UPDATE/SHARE, advisory locks or inserts. It is a UI hint; execution always repeats the locked validation. Never pass readOnly from user input into payment execution.
-
Manual persistence uses a separate proposal tied to original verification, SHA and incremented attachment version. Save and review audit share the transaction; only id/status/version leave mutation endpoints. Association-ready means unchanged item association, not independent approval; manual approval does not itself record payment. Rejected proposals remain immutable history and manual rejection permits explicit OCR retry.
-
Manual endpoints preserve finance management or manager/ceo review permission, source-department and feature gates. Persistence locks batch, claim advisory, then attachment, rechecks original and corrected transaction claims, and rejects stale version/source or self-review. Approval revalidates matching; execute must still revalidate and claim in the actual payment transaction.
-
Manual proposal preparation in paymentReceiptManualReview is not authorization: corrections or unavailable OCR require independent review; unchanged exact-match item association does not. Preserve original evidence, changed-field provenance, and original transaction fingerprints for persistent duplicate checks.
-
Never rewrite original failed/pending transactions into successful evidence, including conflicting status aliases. Original cross-page duplicate identities remain blocked even when manual fields change the reference or payer account. Internal normalized accounts must not be serialized into API responses.
-
Independent exception review requires explicit review permission and manager/ceo business role, outside maker, proposer and every source owner/submitter. Missing identity fails closed. Pure helper validation does not replace route scope, transaction/version guards, audit or execute-time revalidation.
-
OCR upload/retry eligibility requires reimbursement plus a pending/failed bank item. The worker rechecks under the batch lock before reading storage or calling cloud OCR; out-of-scope queued receipts return to legacy without transmission. Cash and travel attachment storage is not permission to send them to BankSlipOCR.
-
New eligible receipt uploads queue in the attachment/audit transaction; legacy attachments are not automatically sent to cloud OCR. cron and pg-boss share DB claiming; the dedicated
payment.receipt.ocrqueue disables automatic retries. -
POST /payment-batches/:id/receipt-attachments/:attachmentId/ocr-retryrequires finance management, feature/department and batch-state gates plus an integerexpectedVersion. Version/state rejection usespayment_batch.receipt_retry_unavailable, not a generic unexplained failure. Requeue and audit commit together. Processing is eligible only after 15 minutes; queued/matched are not eligible. No amount/plaintext or OCR call in this route. -
Attachment GET exposes
ocrState/ocrVersion/ocrStartedAt/ocrRetryAvailable; the same eligibility function is used on retry. Eligibility is not permission: callers still need authorized editable batch context. -
Persistence:
storeReceiptRecognition(tx, config, { batchId, attachmentId, sourceSha256, expectedVersion, recognition })locks batch then attachment, rejects voided/changed/superseded input, and writes encrypted allowlisted evidence. Future scheduling/correction must increment the attachment version under the same locks. -
claimVerifiedReceipts(tx, config, batch)requires a locked current batch and the same transaction as payment recording. Revalidate latest evidence and frozen data, then claim unique transaction fingerprint and item. Never call in a separate committed transaction or consider it complete HTTP authorization by itself. -
Claim lock namespace is
ADVISORY_LOCK_NAMESPACES.paymentReceiptClaims(1002). With Prisma, project an integer frompg_advisory_xact_lock; selecting its void return directly can fail with P2010. Do not remove serialization to hide that failure. -
Different fingerprint key IDs in historical claims block registration until an explicitly reviewed key migration; key rotation must not defeat deduplication. DB validation:
RUN_ISOLATED_DB_TEST=1 node scripts/qa/test-payment-receipt-db.cjs. -
Internal services:
normalizeBankSlip(fields),matchBankSlip(receipt, candidates)in paymentReceiptMatching andrecognizePaymentReceipt({ buffer, mimeType, config })in paymentReceiptOcr. These helpers are not themselves payment authorization. -
BankSlipOCR keys vary by layout; support known aliases, reject conflicting or repeated semantic keys as ambiguous, never silently pick the first transaction. Currency/account/principal matching is per item, not against the batch total.
-
Normalize money with strict grouping/precision and integer minor units; preserve account leading zeroes. Masked accounts and non-final transaction states cannot establish a match. OCR matching never establishes bank authenticity.
-
Process all PDF pages, retain expected/processed counts, and never accept a partially failed response as a complete receipt. Provider errors are sanitized; no raw response or public receipt URL. Normalized accounts remain internal sensitive data, not API/log payloads.
-
Unit contracts: paymentReceiptMatching.test.js and paymentReceiptOcr.test.js. Their passing does not prove persistent duplicate prevention, independent manual review, source-state revalidation, or execute transaction safety; those gates require separate database/HTTP acceptance before wiring payment writes.
-
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: Read-only Reimbursement/Payment Handoffs
1. Scope / Trigger
The portal must expose business work between reimbursement return, approval, batch creation, independent review and payment recording, without a new todo store or notification dependency.
2. Signatures
loadReimbursementPaymentHandoffs(fastify, employee) supplies businessHandoffs: { total, items } on the authenticated GET /portal/home route. Each item has key, kind, reference, title, nextStep, ownerLabel, href, mobileHref; kind is one of reimbursement_edit | reimbursement_batch | payment_review | payment_execute.
3. Contracts
Use a RepeatableRead, READ ONLY transaction (5s acquisition, 10s execution limit). Select necessary identity/reference/stage/department facts, never bank snapshots or amounts. Stable stage/object keys are unique; total equals all returned items, with no silent eight-row cutoff. Mutations retain their own authoritative gates. No schema, new environment setting, persistent todo, decryption or notification send is introduced.
4. Validation & Error Matrix
- Returned work requires participant + submit/read rights and the latest workflow's final return/reject fact; new, withdrawn, paid and deleted drafts are excluded.
- Unbatched approved sources require manage permission, exclude deleted/paid and non-cancelled reservations. Missing bank data remains work, not proof of readiness.
- Review requires manager/ceo + review permission, a different maker, draft/unreviewed status and pending items. Reviewed/failed processing requires manage.
- Payment work requires both global and source-department payment gates. Missing/deleted batch sources do not yield actionable handoffs.
- Query failure follows the existing API error boundary and frontend retry state; never convert it into a successful zero-items response.
5. Good / Base / Bad Cases
Good: review moves work from the non-maker reviewer to finance. Base: genuinely no qualifying work returns zero items. Bad: a historical return followed by withdrawal, self-review, or a hidden source must not produce an action.
6. Tests Required
Service/route tests cover permissions, department gates, latest-history selection, deleted sources, no read side effects and stable counts. Real isolated return/resubmit/create/review/receipt/paid transitions must prove work appears and disappears. PC/mobile tests cover refresh, errors, more than eight items and no double-counting of legacy finance aggregates; source-only or mock-only checks are insufficient for final acceptance.
7. Wrong vs Correct
Wrong: treat a notice count or pending-batch aggregate as the complete finance worklist. Correct: derive four business handoffs from current facts; when the new DTO is present, omit the old pending/failed aggregates from action counts while preserving independent risk reminders. Notification outages do not erase business work.
Scenario: Business Error Reasons And Payment Creation Readiness
1. Scope / Trigger
- Adding or changing user-facing business validation, especially reimbursement, invoice binding/unbinding, payment batches, leave and attendance correction.
- Root cause addressed by task
09-05-error-explainability-payment-readiness: server details and UI text evolved independently; successful static coverage was incorrectly inferred from shared details keys or string counts.
2. Signatures
- Registry:
server/src/constants/errorReasons.js,ERROR_REASONS. - Builder:
buildBusinessValidationError(reasonCode, legacyDetails, statusCode). - Client map:
client/src/api/errorReasons.ts. - Gate:
node scripts/qa/check-error-reason-contract.mjs --strict; negative subprocess fixtures: the same command with--self-test. - Candidate API accepts
paymentChannel; each candidate addsreadiness: { ready, blockers }. POST /api/payment-batches/readinessaccepts onlyentityType,entityIds, and optionaldefaultPaymentChannel; this is authenticated, manage-authorized and read-only. Creation remains independently authoritative.
3. Contracts
- Keep HTTP status, top-level error code and legacy details. Add the registered
domain-scoped
details.reasonCode; do not infer business identity from a details key alone. The builder rejects unregistered reasons. - 迁移一条遗留原因码 = 服务端注册 + 客户端映射 + 具体内容断言,三项缺一不可。
Dynamic guidance requires an additional payload test containing both reasonCode
and its legacy details (missing fields, retry timestamps, failed policy rules).
A reason-only fixture cannot prove preservation of actionable details. Project
only allowlisted fields for that reason; malformed or cross-domain details must
fall back safely instead of bypassing the registered message contract.
Fixed expected text lives in
server/tests/unit/fixtures/error-reason-messages.jsonand is asserted against both registry and client rendering; do not replace these assertions with broad message regexes or expectations derived at runtime. Also check registry → fixture completeness for everyaudience=userreason; iterating fixture entries alone cannot detect a newly registered code omitted from the fixture. - Authentication explanations must retain the same
auth.code_invalid_or_expiredreason for missing, inactive, locked and wrong-code accounts; do not introduce account-enumerating recovery messages or include submitted secrets in errors. - Invoice verification availability is distinct from OCR availability. Explain a disabled verification gate without claiming OCR is disabled or enabling either provider. Invalid or duplicate requests must not enqueue work or write records.
- Workflow input-error migration preserves parsers, permission order and transaction boundaries. Invalid comments, task identifiers and batch requests must fail before business transactions; changing error metadata does not authorize state-machine edits.
- Notification list/delivery/channel validation uses notification.* reasons; todo input/id/query/gate errors use todo.* and the portal gate uses portal.disabled. Preserve legacy fieldErrors/id/featureFlag/reason and 400/409 statuses. A missing channel encryption key must direct original-key repair without exposing input secrets or suggesting a replacement key. Error migration must not send messages, change channel encryption rules, enable flags or loosen assignment permissions.
- Invoice date-only input must survive UTC date round-trip unchanged. Reject invalid timestamps and calendar overflow (including non-leap February 29) before updates or audits; JavaScript Date construction alone is not calendar validation.
- Preserve pre-existing actionable top-level API messages when migrating a reason. Use a fixed registered builder call, retaining legacy details/status and then the original message if necessary; do not bypass the AST gate with direct AppError literals or dynamic, unvalidated reason names.
- Legacy entries are versioned AST-shape SHA-256 fingerprints plus relative file/operation, occurrence count, owner, explanation and expiry date (UTC). Whitespace, quotes, indentation, trailing commas and parenthesization must not change identity. A changed node is a new obligation; one deletion cannot offset one addition.
- The first-wave route files and invoice bind/unbind/shared item resolver are zero-exception operations. Do not baseline their new or old generic errors.
- Candidate rows, combination preflight and the locked creation transaction all
consume
resolvePaymentBatchCreationReadinessfrompaymentBatchReadiness.js.loadPaymentCreationFactsloads sources, reservations, accounts and reviewers in batches. Department flag reads are memoized only inside that fact load; do not cache readiness across requests or write readiness to the database. - Creation takes source locks, reloads facts, runs the resolver and only then allocates/creates a batch, items, success audit and notifications. Blocked preflight/create must not emit any of those success side effects.
- New-payment candidates and
loadEntitySnapshotsboth requiredeleted_at: null; an approved status alone is not the deletion guard. A deleted source yieldspayment_batch.source_not_foundin readiness and HTTP 404 on creation, including when marked between preflight and source lock. Keep deletion-filter awareness in query test doubles and exercise this defensive fixture even though current deletion only permits draft/rejected. - Do not mechanically apply creation filters to historical batch detail, frozen snapshots or legacy evidence reads. Preserve their existing authority checks and test that retained history remains readable. This does not permit deleting approved/reserved sources; any such future change must also review existing-batch export, review and execution paths, not just creation.
- Public blockers contain only reason code and optional source association. Neither decrypted account numbers nor account snapshots enter readiness DTOs. Bank-channel constraints must not silently become constraints on cash or other already-supported channels. No bank capability, role or maker-checker changes.
- Execution keeps persisted
failure_code/failure_messageand their API fields compatible. Store only the registered reason code in the existing execution-riskpayload_json, with a safe detail; refresh that open risk's explanatory metadata inside the same transaction without changing status, creator or notification rules. This is not a readiness field or a new server logging mechanism.
4. Validation & Error Matrix
- Missing source ->
404 VALID_001on create; invalid input ->400 VALID_001. - Status, occupancy, department, missing company/payee/account, decryption,
cross-company and missing independent reviewer -> safe blockers; create
409. - Three synthetic missing fields return three distinct actionable blockers.
- Missing mapping/content fixture, typo, duplicate, changed/new/stale fingerprint, expired exception or primary-operation exception -> gate non-zero exit.
5. Good/Base/Bad Cases
- Good: individually ready records from different companies produce a blocked combination; changing source/account facts changes the next response.
- Base: complete bank fixture produces
{ ready: true, blockers: [] }and can create; a competing reservation after preflight is refused under the lock. - Bad: hide invalid candidates, copy create conditions into an API/UI, approve permanent path exemptions, or mark deployment acceptance from local fixtures.
6. Tests Required
- Gate self-test must launch failing subprocesses and assert their exit codes, including formatting stability, one-added/one-removed and primary exceptions.
- Focused route tests assert exact reason, HTTP status and retained details.
- Shared readiness matrix plus route tests cover batch loading, repaired facts, combination rules, post-lock changes and zero batch/item/audit/notification.
- Keep existing B2, invoice, leave, correction, receipt and maker-checker tests.
7. Wrong vs Correct
- Wrong: count generic error strings, or
details.statusas the cross-domain key. - Correct: register
payment_batch.source_status_invalid, map and assert its concrete safe text, and consume the shared readiness result at every boundary.
Scenario: Prisma Client Defaults In Raw SQL
1. Scope / Trigger
- Trigger: a script or route uses
$queryRaw,pgor handwrittenINSERTSQL 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 NULLwithout a databaseDEFAULT. - Raw insert must include
idand 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 omitid.
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
INSERT INTO audit_logs (request_id, actor_id, extra)
VALUES ($1, $2::uuid, $3::jsonb);
Correct
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 ashyb_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()beforebuildApp()must loaddotenv/configbefore reading config.
3. Contracts
migrate statusis safe and read-only; it may be run before asking the user.migrate deploywrites database schema state and requires explicit user confirmation unless the current task already approved that exact local DB.- Do not use
prisma migrate resetfor E2E recovery without separate backup and destructive-action approval. - Attached local E2E should disable background jobs with
JOBS_ENABLED=falseunless the test explicitly validates job behavior.
4. Validation & Error Matrix
migrate statusreports pending migrations -> record the migration names, inspect whether they are additive/destructive, then ask before applying.- Missing
JWT_SECRETduringnpm run devwhile.envexists -> verify the entrypoint loadsdotenv/configbeforegetConfig(). - 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 resetor manually add columns to unblock a test. - Bad: start the server with an ad hoc
node -r dotenv/configworkaround and leavenpm run devbroken.
6. Tests Required
cd server && npm run prisma:validate.bash scripts/qa/check-local-migration-drift.shwhen local DB drift was the cause.cd server && node --check src/index.jsif server bootstrap changed.- Focused Playwright flow that previously failed.
- Frontend
npm run buildwhen Playwright or client behavior changed.
7. Wrong vs Correct
Wrong
cd server && npx prisma migrate reset
Correct
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_v1from 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:
PortalAnnouncementmay carrycategory,requires_acknowledgement,acknowledgement_statement,acknowledgement_due_at. - DB:
PortalAnnouncementAcknowledgementstores one receipt per(announcement_id, employee_id)with statement/title/summary snapshots andactor_id. - API:
GET /api/announcements?manage=0returns visible announcements and the current employee's acknowledgement status. - API:
GET /api/announcements?manage=1requiressystem.settingsand returns acknowledgement summary plus minimal pending employees. - API:
POST /api/announcements/:id/acknowledgeaccepts{ "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, andstatus; 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_v1disabled ->409 VALID_001.- policy acknowledgement without statement ->
400 VALID_001. manage=1withoutsystem.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
await prisma.portalAnnouncement.update({
where: { id },
data: { title: "制度 V2" },
});
Correct
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 theseed:devnpm 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_URLparsed with the samepgsemantics used by Prisma's PostgreSQL adapter.
3. Contracts
NODE_ENV=productionis 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_invalidordatabase_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.internalbecause onlyURL.hostnamewas 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
if (new URL(env.DATABASE_URL).hostname === "127.0.0.1") allowSeed();
Correct
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 ESMvi.mockintercepting a CJSrequire. - 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.envor 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.envdatabase is reachable.
6. Tests Required
- Run health and static tests with
DATABASE_URL='' JWT_SECRET=''. - Run the full
scripts/check-minimal.shunder the same empty environment. - Keep the production default path covered by normal startup/smoke checks.
7. Wrong vs Correct
Wrong
vi.mock("../../src/prismaClient", () => ({ createPrismaClient: fake }));
await buildApp(); // CJS plugin may still construct the real client
Correct
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_codesandemail_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
!challengeafter 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
await tx.authSession.updateMany({ where: { id: sessionId, revoked_at: null } });
await tx.authSession.create({ data: replacement });
Correct
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/cancelwith{ 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|ceoreviewer; 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_v1is 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|faileditems. Existingexecuteditems 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_001and 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
executedwith 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
summary.successCount = successesInThisRetry;
batch.status = failuresInThisRetry ? "failed" : "executed";
Correct
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-localYYYYMMDDinteger. - 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-
int4form with an explicit, inspectable object key. Do not usehashtextor 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
P2002and retry inside the same interactive transaction; any fallback must retry the complete transaction from the beginning.
4. Validation & Error Matrix
$queryRawreadsSELECT pg_advisory_xact_lock(...)-> Prisma driver may fail withP2010because PostgreSQL returns avoidcolumn.$executeRawexecutes the sameSELECT-> 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_001and 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-localagainst a disposable PostgreSQL database. The daily-sequence race must create two different sources concurrently and assert two consecutive unique numbers. - Run
node --checkon the route, namespace registry and concurrency script, plus Prisma validate and a read-only review for affected payment paths.
7. Wrong vs Correct
Wrong
await tx.$queryRaw`SELECT pg_advisory_xact_lock(${namespace}, ${dateKey})`;
const latest = await readLatestSequence(tx);
Correct
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
ContractRegistrytermination, archive corrections, all import or batch writers, and any future endpoint that can mutate the same row.
2. Signatures
POST /api/contracts-registry/:id/terminatewith{ reason }where the trimmed reason is 10–1000 characters.POST /api/contracts-registry/:id/correctionswith{ summary }for an archived row only.- DB fields:
status,terminated_at,terminated_reason,terminated_by; the employee foreign key usesON DELETE RESTRICT. - Required audit helper:
writeRequiredAudit(tx, request, action, targetId, extra); it must throw on insert failure.
3. Contracts
status = terminatediff all threeterminated_*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_atversion 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_001and 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
terminatedwithout 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 --checkon 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
await prisma.contractRegistry.update({ where: { id }, data: { status: "terminated" } });
await writeAudit(prisma, request, "CONTRACT_REGISTRY_TERMINATED", id); // swallows errors
Correct
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 });
});
Scenario: Reimbursement Draft Business-Fact Visibility
1. Scope / Trigger
- Trigger: changing reimbursement list/detail/material/search/export/statistics read scope or adding a new record-level read surface.
expense.view_allcovers submitted business facts; it does not turn another employee's never-submitted draft into a shared ledger record.
2. Signatures
- Shared builder:
buildReadableReimbursementWhere({ employee, scope, filters })inserver/src/services/reimbursementVisibility.js. - Object check:
canReadReimbursementBusinessFact(prisma, reimbursement, employeeId). - Workflow fact:
workflow_instances.entity_type='reimbursement' AND entity_id=<reimbursement-id>; Prisma relation filters may useworkflow_instances: { some: {} }because the relation is reimbursement-specific.
3. Contracts
- Owner or submitter keeps existing access to their draft. A non-draft keeps the existing role/team scope. Another employee's draft is readable only if at least one reimbursement workflow instance exists.
- Returned and withdrawn drafts remain business facts regardless of the latest workflow status or outcome. Never replace existence with a pending/latest/outcome whitelist.
- Apply the shared condition before pagination/counting to list, global search, reimbursement exports and dashboard/statistics. Raw SQL uses an indexed
EXISTSonentity_type + entity_id. - Detail, attachment file, attachment preview URL, material bundle and read-only
precheckfail closed through the same fact check. A current or historical workflow reviewer remains independently readable through task assignment and is not granted edit permission.
4. Validation & Error Matrix
- Other employee +
draft+ no workflow -> excluded from collections and403 PERM_001on known-ID reads without revealing why. - Other employee +
draft+ workflow exists -> existing global/team/reviewer scope applies. - Owner/submitter draft -> existing own permission applies without requiring workflow history.
- Missing status/object or failed fact lookup -> deny; never assume a missing value is non-draft.
5. Good/Base/Bad Cases
- Good: finance sees a returned draft but not a colleague's unfinished first draft; the same result holds in list, search, export, statistics and materials.
- Base: a reviewer without
expense.view_allopens the reimbursement attached to a current or historical assigned task. - Bad: hide drafts only in React, filter every
draft, or letprecheckexpose policy/risk facts for an otherwise unreadable draft.
6. Tests Required
- Table-driven global/team/own scope and owner/submitter/non-draft/returned/withdrawn/never-submitted classification.
- Route tests cover all five object-level read entries, including
precheck, plus a reviewer non-regression. - Structural and route tests keep list, search, both reimbursement exports, dashboard/statistics and raw SQL on the shared fact contract.
7. Wrong vs Correct
Wrong
if (hasPermission(employee, "expense.view_all")) return reimbursement;
Correct
const where = buildReadableReimbursementWhere({ employee, scope: "all", filters });
await canReadReimbursementBusinessFact(prisma, reimbursement, employee.id);
Company Loan Principal And Retired ERP Consumers
09-17-company-loan-principal-repaymentadds the explicitloan_repaymentpayment source: one retained repayment/batch/item, multiple allocations to confirmed same-company/lender/CNY loans. Preserve original principal and confirmed opening evidence; Decimal reservations and settlements must not exceed opening outstanding principal. SQL checks/deferred triggers complement locked application validation.- Loan management requires an explicit finance/finance_mgr role plus loan.manage. Independent review requires active manager/CEO with review permission and excludes maker, submitter and lender OA identity; admin wildcard cannot replace these identities. External lenders require explicit verified no-OA identity. Manual account changes revoke identity confirmation and invalidate old unpaid snapshots; explicit employee-source refresh renews confirmation under the checks below.
LoanLenderAcknowledgementis the lender's declaration, separate from company-representative authority. Finance proposes 1–50 unconfirmed loans for one verified employee lender/company, freezing principal, opening balance/date, incoming originals and identity versions.loan.acknowledge_ownpermits only the frozen employee's own records/originals/decision; manager or administrator status cannot substitute for that identity. Keep the finance maker distinct, originals checked on confirmation, strict request-key/hash binding and audit atomicity.- Header, scope snapshots and the single confirm/return decision are immutable. SQL triggers invalidate the whole group permanently after any member's material terms, incoming original or lender association/version changes; restoring source values cannot reactivate it. Additional accounting evidence, balance confirmation and ordinary loan version increases do not invalidate. Returned/invalid statements can be reissued; overlapping current pending/confirmed scopes cannot. After balance confirmation, reissue is allowed only with invalidated declaration history for that loan; never rewrite the original balance confirmation, loan version or terms. Service, SQL scope guard and
lenderAcknowledgementRenewableUI projection must agree. Purely paper-confirmed loans do not gain first-time online declaration scope. assertEvidenceCompleteaccepts paper agreement OR a valid confirmed declaration. Incoming originals, accounting reconciliation and required historical-repayment adjustment evidence stay mandatory at balance and repayment consumers. Balance confirmation must lock lender then loan, reread the lender association and evidence, and refuse after a concurrent invalidation; locking only that loan misses sibling-original and identity changes. Declaration writes lock lender → sorted loans → declaration → employee (FOR NO KEY UPDATE). Preserve existing payment-batch lock order.- Declaration-scoped downloads expose only frozen incoming originals after current source and physical digest checks, with purpose, audit and no-store; never expose accounting attachments or other loans via this scope. JSON POSTs through
apiRequestBlobmust explicitly setContent-Type: application/json. UI requests are actor-scoped and cancellable; a late 401 cannot replay an old decision under a new account. Ordinary employees have no permanent company-loan entry in workspace navigation, NavLinks fallback or MobileMePage; keep own-only route access independent of navigation visibility. Finance and company representatives retain their existing entries. - Proposals create one
loan.lender_acknowledgement_requestedin-app notification for the frozen lender employee, in the same transaction as the declaration and audit. Idempotent retries do not create another notification, and a notification write failure rolls back all three. Keep copy free of lender names, amounts, bank details and originals; do not add this type to IM support or external delivery/outbox. Desktop/mobile notification targets useloan_lender_acknowledgementand the declaration ID to open details vialenderAcknowledgementId, independent of the list page. The URL ID is the single source of current selection and overrides proposal mode; ID changes clear drafts/confirmation state and cancel old requests. Existing ownership and invalidation checks still govern historical links. - Verify declaration contracts with
RUN_ISOLATED_LOAN_ACK_ONLY=1on the isolated loan runner andRUN_ISOLATED_LOAN_BROWSER=1for desktop/mobile originals, confirm/return, accounting requirement and actor-switch cancellation. Retain full-loan regression. Migration20261010010000_loan_lender_acknowledgementsadds history without backfilling acceptance; preserve it on rollback. An older application cannot consume online-only agreement evidence, so post-use rollback requires compatibility planning. - Employee-source preview/link/refresh additionally require
employee.bank.readoremployee.bank.manage.loanLenderEmployeereads the active employee, supported bank and encrypted account on the server; the strict write body carries employee/company IDs, source version and an explicit confirmation, never client-supplied account/name replacements. A source version binds actual source fields, ciphertext and the chosen debtor, not onlyupdated_at. Missing, unreadable, stale or unsupported sources reject without a partial lender/audit write; previews expose masks only. - Same-company employee association includes archived/history rows in duplicate checks, including the old identity-confirmation entry. Reuse a unique matching lender; reject ambiguous links and require explicit refresh for different data. Existing-lender actions lock lender before sorted employees. New creation never locks an existing lender after employees: finish and reacquire in the correct order if a link appeared. Use employee
FOR NO KEY UPDATEto serialize association/source edits while allowing audit/actor FKKEY SHARE;FOR UPDATEcan deadlock against another employee's refresh/audit. Persist and serialize each actor's request-key binding for both creation and reuse, with audit in the same transaction. - Explicit employee refresh rejects reserved repayments, checks source/identity/account versions and advances both lender versions with renewed finance identity confirmation. This invalidates old company-representative grants without changing principal or historical payment snapshots. Employee-linked lenders cannot use the manual account PATCH; employee-master edits alone never rewrite the loan copy. Verify the actual audit-FK interleaving, duplicate/legacy races, refresh versus repayment creation, audit rollback and reverse mutations using
RUN_ISOLATED_LOAN_EMPLOYEE_ONLY=1and optionalRUN_ISOLATED_LOAN_EMPLOYEE_MUTATIONS=1on the isolated loan runner; retain the original full-loan regression. - Related-party loans use the explicit
loanApprovalPolicybranch, not a global reviewer exception. Finance proposesLoanRelatedAuthorizationfor one company/lender, its verified employee identity and specific confirmed loans; that designated active manager/CEO confirms it. Grants, scope and lifecycle events are append-only. Resolve the latest confirmed version without falling back to an older grant when the new scope is narrower or stopped; a superseded version cannot receive stop events. - The authorization selector calls
GET /loan-lenders?eligible=related_authorityunder the existing finance/loan.managegate. Filter verified, employee-linked, non-self lenders before both pagination and count; this selector filter does not replace final representative-role/scope checks. Omittedeligiblepreserves the ordinary list, unsupported values reject, and opening the dialog resets to page 1. Browser pagination waits for a loaded, changed option list and checks the intended IDs, including cache hits; a network response alone is insufficient. After evidence upload, select the exact returned evidence ID when its option is attached, never the last option or placeholder. Verify genuine page 3 with desktop/mobile scope registration and designated confirmation, an unchanged-page counterexample, and distinct synthetic payer/payee accounts for direction refusals. SetRUN_ISOLATED_LOAN_BROWSER_DIAGNOSTICtoauthorization-paginationorrefundwith the existing isolated DB/browser switches; diagnostic checks do not confer release eligibility. - Freeze
LoanRepayment.approval_mode,authorization_idandapproval_contextat creation. Onlyrelated_partywaives the designated payee-conflict reason. Keep maker/submitter/exporter/uploader/manual-entry/proposer conflicts as a separate union of all history, including voided and superseded materials; do not remove the payee employee from that union. Reject designated-reviewer participation at maker entry points as well as decision/final-consumer checks. Old independent loans and shared reimbursement/travel rules do not switch modes. - New payment creation/approval/export checks current payment authority; new reconciliation decisions and final reservation release check current reconciliation authority.
decisionFields()stores purpose, actual actor, target, scope, authorization version/event and conflict identities.assertDecision()validates immutable historical provenance at receipt reconstruction, settlement, terminal/recovery consumption and bank-event booking; it does not require the old reviewer to remain active or the old grant to remain enabled. Stopping payment and stopping reconciliation are separate events. Previously approved bank facts remain usable if their evidence and all money/transaction checks still hold. - Related payment review may return an application with
decision=rejectand a reason. This records an immutable decision without releasing reservations; it cannot later be approved, exported or paid. Finance uses safe never-sent cancellation and creates a corrected application. The UI, responsibility and notification consumers must distinguish this from an approved payment or an independent review. - Shared loan error reasons must describe confirmation under the batch's rules rather than require independent review for every mode. Keep registry text, client mapping and fixed message fixtures together; independent-only reviewer failures and Q6 still state their independent-review requirements. Wording does not relax any actor or evidence guard.
- Creation locks lender, sorted loans, then day sequence; it never waits for a previously existing batch. Existing settlement locks batch, global receipt identity, attachments, lender/reviewer, sorted loans. Source maintenance never locks an existing batch. Claims, allocation settlement, batch/item payment facts and audit commit atomically.
- Authority lifecycle changes lock the lender and never wait for an existing batch. Existing-batch decisions acquire the lender only after the batch/receipt locks, then repeat authority checks; creation locks lender and loans and creates a new batch. Test both interleavings of stopping authority against approval, export, fact confirmation and final release; do not introduce lender-to-existing-batch lock inversion.
- Export/upload marks bank outcome unknown without reducing principal. Unknown/in-transit cannot release reservations. A never-sent cancellation or current, exactly matching failed-bank evidence confirmed under the frozen approval mode may release; preserve the old request. Paid and terminal allocation/history facts are immutable. Accounting handoff is a separate audited reference, not a second payment state.
- Repayment BIFF8 uses encrypted account-name snapshot and purpose 还款/归还借款本金; receipt matching/manual review explicitly supports loan source and shares the reimbursement claim/alias namespace. Real bank/OCR acceptance is not established by synthetic recognition tests.
- Loan evidence uses governed PDF/image bytes, random object keys, bounded content/hash checks and audit-before-download. Finance reads the ledger; reviewers can download only materials in an eligible payment's allocations or their own designated authorization scope, not the full ledger. Related reviewers retain scope-limited historical reading after authority stops while still requiring current reader eligibility. Full accounts/originals require purpose, audited access and no-store responses; do not grant global
loan.manageto make review pages accessible. UI capabilities project the same policy and never replace transaction-level authorization. - SR recovery of a still-reserved
confirmed_failed/confirmed_not_paidbatch never rewrites the bank outcome or reopens export/payment. Once any recovery round exists, final cancellation must consume the latest valid approval; rejected, withdrawn or superseded rounds must not fall back to the original cancellation path. Consumption, allocation release, batch cancellation and audit share the transaction; deferred SQL checks protect the release/consumption pairing. - Recovery snapshots bind material revisions, submission/fact/receipt identities, verification versions, participants under the explicit approval mode and the current same-amount candidate set. Material writers (including OCR persistence) serialize on the batch; candidate formation also locks the lender. A local batch revision alone cannot detect another batch becoming a candidate. Validate at submission, approval and final cancellation, with physical document digest checks before release.
- JSONB can reorder object keys. Compare snapshots using recursive key-canonicalized hashes, retaining array order, rather than hashing raw
JSON.stringifyresults before and after storage. Keep snapshot payloads out of public response mapping. New evidence for the same bank transaction still requires new review. - Verify loan and recovery paths through
RUN_ISOLATED_LOAN_DB_TEST=1 node scripts/qa/test-company-loan-db.cjswith the locked BIFF8 Python environment: real HTTP/evidence/OCR, stale-round repair, no fallback after withdrawal, atomic rollback and deterministic interleavings with evidence, submissions, OCR and candidate formation. AddRUN_ISOLATED_LOAN_BROWSER=1for actual desktop/mobile actions, including the two-actor related workflow; mock only cloud OCR output.RUN_ISOLATED_LOAN_RELATED_ONLY=1selects the related suite, andRUN_ISOLATED_LOAN_RELATED_UI_ONLY=1is browser setup only and must report HTTP as not run. Preserve the old independent regression; inline self-review is not independent review. - Manual no-submission is a separate terminal fact, never a bank termination claim: fixed documentary categories, complete reviewed export/submission inventory, two distinct independent reviews, material/candidate snapshot and physical digest checks at final release. An empty submission attestation is valid only for actual exports and no historical submission rows; withdrawn submissions do not prove non-submission.
- Q6 manual no-submission is unavailable in related-party mode at proposal, review and final release, with database guards as well as hidden controls. Neither two-person confirmation nor OCR supplies missing attribution, instruction termination or return evidence. Once related decisions exist, do not downgrade to code that cannot interpret their mode/provenance or delete those fields; stop new payments and use an approved compatible fix.
- Late-payment supplements and bank returns use an append-only ledger, not exportable payment batches. Include supplemental principal in both application balance calculations and SQL capacity checks. Their keyed transaction claims must exclude ordinary receipt claims and aliases in both directions under the shared receipt lock. Freeze causes close independently; partial returns and unresolved reservation conflicts keep their own causes active. The ordinary pre-payment gate must not block factual reconciliation.
- ERP-specific API/adapters/jobs/enqueues are retired by
09-17-erp-exclusive-retirement. Shared outbox/notification/WeCom and historical records remain. New ERP enqueue and direct dispatch reject; pending selection excludeserp.*; historical retry returnsintegration.erp_retiredwithout mutating records. Never restore these retired consumers solely to satisfy old success-path tests.
Purchase draft transaction and response hydration
- Keep purchase draft creation and its audit atomic. Inside the create transaction, select only identifiers needed for the audit; hydrate the original detail relation projection after commit.
- Draft PATCH likewise hydrates the same response projection after commit. Keep FOR UPDATE, ownership/draft guards, master-data and project validation, item replacement, amount calculation, both existing parent updates and audit in their original transactional order. This response is current committed detail, not a relationship snapshot protected by the earlier write lock; never move a read used for a business decision outside the transaction.
- Cover omitted, empty and populated item lists; fail writes/items/audit/commit before any response read. A failed response read after commit is an error response, not a rollback, and must never trigger automatic patch retry. Review each write entry separately: a creation fix does not establish update/submit/withdraw/archive coverage.
- Do not increase transaction deadlines, connection pool size or retries to conceal a measured P2028. Preserve permission/cutover/default fields and audit ordering.
- Creation or audit failure must prevent post-commit hydration. A hydration failure after commit is not a rollback and must not trigger automatic draft creation retry.
- Test ordering and failures with stubs, and separately exercise the actual create/approve/archive flow in the managed synthetic database. Stub rollback assertions do not prove PostgreSQL fault-injection rollback.
Asset and system-user creation under local load
- Asset creation keeps reference validation, number allocation, asset and optional assignment writes in the original transaction. Return the scalar audit/assignment fields, then hydrate the unchanged detail projection after commit. Preserve the existing post-commit, nonblocking asset audit and its independent failure logger; do not copy purchase's in-transaction audit semantics into this route.
- Failed asset/assignment/commit must not hydrate or emit a successful creation audit. Post-commit detail failure does not roll back the asset and must not retry creation; missing detail follows the existing asset.not_found contract. The detail represents current committed state, not an atomic relationship snapshot from the write.
- Single-user creation performs the unchanged cost12 password hash after existing permission/input/protected-role/reference guards but before opening the account/role/audit transaction. Only server code supplies the internal passwordHash argument; never accept a client-provided hash. Preserve generated credentials, forced password change, unique-conflict handling and post-commit directory sync. Batch imports retain their existing transaction-local hash position.
- Keep original transaction deadlines, password cost, retries and worker count. Test with real hashing for the pre-transaction boundary, plus hash/write/role/audit/commit/read failures and original import/permission flows. Mock transaction ordering is not PostgreSQL rollback or whole-load reliability evidence; retain a fresh complete managed run.
Reimbursement submit precheck reads
- Resolve the reimbursement entity and ordered item/type projection once within each precheck call, and share only that server-read record between private budget, expense-standard and operational checks. Public helper calls still load their own records. Keep rule/assignee/evidence checks, missing-record failures and per-call freshness; do not accept client snapshots or introduce a cross-request cache.
- Submit retains its FOR UPDATE, aggregate total recomputation, budget/approval/history/audit transaction and original deadline. Reuse the already-read item category for travel-summary validation; a linked travel request still requires that summary. Only the legacy invoice gate needs a whole-form count when required-invoice items exist; the enabled per-item gate remains authoritative.
- Test both budget flag states with actual enabled flag fixtures, preserved financial/timeliness/evidence results, missing/read-failure boundaries, non-reimbursement branches and subsequent-call freshness. Travel allowance fixtures need real travel-summary fields even when testing invoice exemptions. Query reduction is bounded optimization, not proof that an intermittent P2028 or complete-load reliability is resolved.
Scenario: Versioned Business Numbering
1. Scope / Trigger
New in-scope internal numbers use businessNumbering v2 only after an explicitly activated policy. Reimbursement is monthly; PB/JK/HK/CG/PD/automatic PRJ annual; FA and existing EA prefixes permanent. Contracts, employees, external source identifiers and frozen travel stay outside this mechanism.
2. Signatures
resolveNumberingContext(tx, { version?, now?, readOnly? })returns the pinned rule version and instant. Only internal callers supply version/now. Config reads use readOnly; mutations require the caller's interactive transaction.issueBusinessNumber(tx, { family, now, timeZone, prefix? })returns persisted documentNumber, businessDate and serial;issueDocumentNumberselects legacy/v2, accepting internal numberingVersion and assetType.- Tables: business_number_policies (version 2, immutable effective_at), business_number_counters (key + bigint high-water); purchase_requests.numbering_version defaults to immutable 1 for old/unversioned drafts.
node server/src/scripts/manage-business-numbering.js --helpdocuments status/activate/pause/resume. It does not load .env, accepts only explicit timestamp/actor/change reference and requires the operator's compatible-writers assertion; this flag is not automated proof of fleet readiness.
3. Contracts
- Migration adds structures, never activates or rewrites business numbers. New purchases pin eligibility at creation but obtain date/number on first submission; historical missing reimbursement numbers explicitly use version 1. Existing numbers, UUIDs, source snapshots and exported bank row identity remain unchanged.
- Policy shared lock
(1003, 0)precedes policy read; management takes the exclusive lock and waits for in-flight writes. Counters lock namespace1004, family keys 1..8 from BUSINESS_NUMBER_FAMILIES; asset prefixes share key8 but retain separate counters. Keep lender→sorted loans→shared policy→HK counter→repayment→PB counter order. HK and its PB share the same context. - Scan matching occupied numbers and persistent high-water under the counter lock, then save in the same transaction as the object. Four digits are minimum padding, never a truncation limit. No reuse after object cancellation/deletion; failed uncommitted transactions roll back. Matching manual project numbers reserve high-water in routes and importer without renaming formal codes.
- Policy effective time, old draft version and counter high-water are protected against changes/decreases/deletion by migration triggers. Pause/resume preserves all issued numbers and counters; pinned v2 drafts still issue v2 while policy is paused. Roll back only to a compatible application; do not remove these structures.
4. Validation / Error Matrix
- Missing/inactive/future policy → legacy; failed policy access or missing migration/model → error, never a silent legacy fallback.
- Unsupported version/family or invalid prefix → fixed error before writes. Capacity beyond PostgreSQL bigint → fixed exhaustion error, no truncation.
- Default activation time is read after waiting for the exclusive policy lock and compatibility reads; do not capture it in a parameter default before waiting. Existing legacy reimbursement YYYYMM / purchase or inventory YYYY configurations conservatively block activation/resume for a specific coexistence review; status lists only affected types, never silently edits the old rules. Existing same-format source rows are separately handled by the locked maximum.
- Old effective date or repeated activation → rejected; missing compatibility assertion/actor/reference → rejected. Policy update and audit share one transaction.
- Counter decrease/deletion, effective-time rewrite or draft version rewrite → PostgreSQL 23514. Unique violation → complete transaction rollback, never catch-and-retry within the aborted transaction.
5. Good / Base / Bad
Good: old PB-20260917-001 remains; new PB-2026-0001 can contain both BX date formats. Base: no activation means legacy behavior. Bad: reformat existing PB because its derived bank customer serial would change, reset a counter, or select a draft's version on submission day.
6. Tests Required
businessNumbering.service.test.js plus affected create/submit/payment/file routes cover formats, fixed time, timezone, boundaries, overflow, legacy and active policy. RUN_ISOLATED_NUMBERING_DB_TEST=1 node scripts/qa/test-business-numbering-db.cjs uses an owned network-none PG, all migrations, real concurrent writes/unique rollback, occupied numbers, import and CLI, immutable history and shared/exclusive locks, then authenticated loan/repayment/payment HTTP regression. Preserve its failures; unit mocks do not prove DB locking. G20 browser release acceptance and production activation remain separate.
7. Wrong / Correct
Wrong: SUBSTRING(code FROM $1) with an untyped bound offset can resolve PostgreSQL's text-regex overload and miss occupied numbers. Correct: SUBSTRING(code FROM $1::int)::bigint, with static allowlisted identifiers and bound regex/prefix values. Keep a real occupied-record integration assertion, not just a stub returning a maximum. Do not use count+1, no-lock MAX+1, or reset old DocumentSequence rows to implement periods.