Skip to content

Lock order

Every transaction takes row locks in one declared order per module, so two writers can never wait on each other in a cycle (40P01). This file collects those orders. Each one is also written at the function that follows it; that comment is the source of truth, and this file points at it. When you change an order, change both.

  • audit.Write comes last. It takes advisory lock class 1 (the audit chain). Nothing may take a row lock or another advisory class after it (internal/kernel/audit/audit.go, the class-1 rule). A workflow guard never calls audit.Write: it runs inside workflow.Transition, before the Poster.
  • workflow.Transition takes the instance lock. Inside it come the kind’s guard, the decision and the Poster (internal/platform/workflow/instance.go). The caller’s own header lock always comes before it.
  • Several rows of one table are locked in id order.
  • No lock is held across a network call. The MyInvois worker and cancel commit before calling LHDN and open a new transaction afterwards.
Module Order Where
Expenses, advances, petty cash the record’s row → workflow.Transition → audit expenses/claim.go, advances/advance.go, pettycash/spend_transition.go
Fiscal close periods before their year; then the reconciliation set and the BR-11 bank gate read bank_reconciliation without locking it (banking never takes a fiscal lock, so no cycle) accounting/closing/closing.go
Opening balance on a tax payable every sst_payable/wht_payable account the entry names, in one SELECT 1 … WHERE id = ANY(…) ORDER BY id FOR UPDATE before the lines are planned (fix round 3 N-4: sorted by id, whatever the line order), then each line’s separate re-read → the journal’s number row → audit accounting/ledger/post.go Post step 4b
Tax codes and tax controls account FOR UPDATE (the lock alone), then a separate re-read of its control kind → the tax_code write → audit. Creating a code and reactivating one lock the account the code names; updating an account locks it before reading it, so X3’s check on either side sees the other’s committed row (D3 fix round 1 I-2) accounting/chart/taxcode.go checkTaxControl, accounting/chart/account.go UpdateAccount
AR documents in a one-member organization, organization FOR UPDATE (workflow.LockSoleOwner, Ruling O-3a) → header FOR UPDATE → a credit note’s original FOR UPDATE → numbering.Allocate → workflow.Transition → a credit note’s self-allocation → audit → event receivables/transition.go TransitionDocument
AR receipts and allocations receipt (the source) FOR UPDATE → invoices FOR UPDATE in id order → workflow.Transition → audit. ar_allocation_guard re-takes the invoice and then the source, which the transaction already holds receivables/transition.go TransitionReceipt, receivables/allocation.go
AP invoices header FOR UPDATE → checks, including the batch hold → cancelled_at → workflow.Transition → audit payables/transition.go TransitionInvoice
AP credit-note offsets credit note FOR UPDATE → invoice FOR UPDATE payables/allocation.go
AP payments and WHT in a one-member organization, organization FOR UPDATE (workflow.LockSoleOwner, owner confirmation D1) → payout: payment FOR UPDATE → supplier FOR SHARE (bank check) → invoices FOR UPDATE in id order (then each self-billed invoice’s einvoice_submission status is read, unlocked: Ruling O-2). Void or reject: payment FOR UPDATE → its wht_deduction rows FOR UPDATE in id order → the allocated invoices. Both then → workflow.Transition → audit. Create and Update (draft) take company_profile FOR SHARE (Update: after the payment header) before the X5 base-currency check, so it conflicts with company.Update’s own lock (D3 fix round 2 N-2) payables/transition.go TransitionPayment, payables/payment.go CreatePayment, UpdatePayment
Supplier bank and payee verification in a one-member organization, organization FOR UPDATE (workflow.LockSoleOwner, owner confirmation D1) → party_supplier FOR UPDATE (VerifyBank) or member_payout_profile FOR UPDATE (VerifyProfile) → audit payables/supplier.go, payout/verify.go
WHT remittances in a one-member organization, organization FOR UPDATE (workflow.LockSoleOwner, owner confirmation D1) → remittance FOR UPDATE → its deductions FOR UPDATE in id order → workflow.Transition → audit. Create and Update (draft) take company_profile FOR SHARE (Update: after the remittance header) before the X5 base-currency check, same reasoning as AP payments (D3 fix round 2 N-2) payables/remittance.go TransitionRemittance, CreateRemittance, UpdateRemittance
Payment batches in a one-member organization, organization FOR UPDATE (workflow.LockSoleOwner, owner confirmation D1) → payment_batch FOR UPDATE → supplier_payment rows FOR UPDATE in id order → expense_claim rows, then advance rows, then payslip rows (payroll P1, PD-27), FOR UPDATE in id order (C4) → party_supplier and member_payout_profile rows FOR SHARE → invoices FOR UPDATE in id order (mark paid locks every paying payment’s invoices before the first move) → mark paid: every live payment_batch_line row FOR UPDATE in id order, explicitly, before the first audit (the employee lines’ outcomes are written after their moves have audited, and a payslip’s move inserts payslip_payment, whose foreign key key-shares its line; ResealBatchSnapshots holds lines FOR UPDATE and then audits, so taking them after an audit closed a cycle: TestMarkPaidWaitsForTheBatchLinesBeforeTheAuditLock) → the line outcome updates (failed, then the payments’ paid) → per paying payment, its own supplier_payment row (already held) and its live wht_deduction rows, re-dated to the value date (X4, D2) → each payment’s workflow.Transition, its audit deferred → each claim’s and advance’s own expenses/advances.Transition, which audits that line inside its move, then each payslip’s payroll.PayPayslip (its payslip row, held → payslip_payment insert → workflow.Transition → audit), so the second and later employee lines take their instance and posting locks after an audit (every batched row is already locked; no cycle found, C4 review M2). When the batch pays payslips, mark paid also key-shares, before the first audit and right after the lines, the batch’s bank account and every account a run’s accrual credited with one of those payslips’ net pay (the payroll payable the payout debits), in id order (payroll.LockPayoutAccounts, review I-1): in a mixed batch a claim’s move audits first, and chart.UpdateAccount / checkTaxControl hold an account FOR UPDATE and then audit (TestMarkPaidWaitsForThePayrollPayableBeforeTheAuditLock). Accepted residual, the C4 M2 class: the payslip_payment insert’s organization and marker organization_member key-shares, and the later moves’ posting rows (journal, numbering, party), may still be first taken after an earlier line’s audit; the organization and marker key-shares are in practice held already, from the first move’s workflow_decision insert (its org_id and actor_member_id foreign keys), which comes before that move’s audit → the payments’ audits → the batch’s audit, last. The batch never locks payroll_run payables/batch.go ApproveBatch, reserveInvoices, MarkBatchPaid; expenses/transition.go; payroll/pay.go
Bank reconciliation bank_account FOR UPDATE (lockBankAccount: the lock alone, then a re-read) → ledger.Post → banking rows → audit. The deferred DB guards take the bank-account lock too. Unlock is the same order (its guard re-takes the lock). Lock (X9) takes organization FOR UPDATE first in a one-member organization (workflow.LockSoleOwner, Ruling O-3a), then the bank account. A period or year close reads bank locks (BR-11) after its fiscal locks, without locking banking rows: the gate is decided at the close. Deactivating an account takes the bank-account lock before it reads the ledger (BR-11). Auto-match’s batch rule reads paid payment_batch rows without a lock: a paid batch is final banking/doc.go
Budgets approval: in a one-member organization, organization FOR UPDATE (workflow.LockSoleOwner, owner confirmation D1) → budget_version FOR UPDATE → cells → audit. Revise takes no lock; the one-draft unique index serializes it accounting/budget/budget.go lockVersion
e-Invoice, retry, cancel and portal check a cancel in a one-member organization: organization FOR UPDATE first (Ruling O-3a) → the source document’s header FOR UPDATE (sales_invoice or purchase_invoice) → einvoice_submission row FOR UPDATE → on a cancel, the source’s void (workflow.Transition, Poster, the source’s audit) → the row’s audit einvoice/manual.go Retry, MarkCancelled, RecordPortalCheck; modules/einvoice/submission_handlers.go cancel
e-Invoice worker claim rows FOR UPDATE SKIP LOCKED and mark them, commit → call LHDN with no transaction open → record the outcome in a new transaction, audit last einvoice/worker.go RunOrg
Payroll setup (Phase 5 P1: settings, salaries, pay item types, employee items) the record’s own row (payroll_setting FOR UPDATE; pay_item_type FOR UPDATE; employee_pay_item FOR UPDATE) → audit last. AddSalary and AddEmployeeItem insert only and take no explicit lock: the insert’s foreign keys key-share, in constraint creation order in 00900_payroll.sql, organization (the org_id column reference, first), then employee, then (employee_pay_item: pay_item_type, then) organization_member. That is the H1-14 order (organization → employee → organization_member), but it holds only because of that creation order, not by construction; reordering those constraints would break it. A lifecycle transition holds organization FOR UPDATE, which conflicts with the key-share only by making the insert wait before it holds anything the transition wants, then audit is last for both, so there is no cycle payroll/setting.go, salary.go, items.go
Payroll audited reads (run detail GET /payroll/runs/:id, run list GET /payroll/runs, employee items, salary history; payables GET /payables/batches/:id, the batch list when it returns a payslip-bearing batch to a holder, and batch candidates when they show payslips, payables.AuditPayslipRead, AuditBatchListRead) no row lock at all: the reads are plain selects in the request transaction, then one audit.Write last (AuditRunRead, AuditRunListRead, ReadEmployeeItems, SalaryHistory, AuditPayslipRead); the write routes that return the run detail audit their write and add no read row payroll/run.go, items.go, salary.go; payables/batch.go
Payroll run (Phase 5 P1: create, recalculate, inputs) Create: organization FOR KEY SHARE → the run’s employee rows FOR KEY SHARE in id order → their organization_member rows (and the preparer’s) FOR KEY SHARE in id order → the included employees’ employee_pay_item rows in id order → the pay_item_type rows those items name in id order → the setting’s payout_channel row, all FOR KEY SHARE (preLockForCreate, H1-14 order, taken before anything is audited because workflow.Start audits and CreateRun then inserts rows whose foreign keys key-share the same rows: without the pre-lock an HR transition holding employee FOR UPDATE, or UpdateItemType, EndEmployeeItem or payout.UpdateChannel holding their row FOR UPDATE, each queued for the audit lock, would deadlock with the create; those setup paths lock one row and then audit, so nothing else is ordered against them) → numbering counter → workflow.Start (instance insert, audits) → payroll_run insert → UPDATE payroll_run (calc_no, revision) → payslip and payslip_line inserts → workflow.SetAmount (audits) → audit. Recalculate / AddInput / RemoveInput: payroll_run FOR UPDATE (lockRun) → input insert or soft-remove → UPDATE payroll_run → payslip and line inserts → SetAmount (audits) → audit; “audit last” holds for these because nothing is locked after SetAmount. The inserts’ foreign keys also key-share salary and payroll_run_input, which have no FOR UPDATE path (salary is insert-only; inputs are only soft-removed by the run holder), so they need no pre-lock; every other row the payslip inserts reference is already held by preLockForCreate, so the constraint trigger order among them no longer matters on create. Recalculate and AddInput hold no audit lock while inserting (SetAmount audits after the last insert), so their key-shares only wait for a holder that is itself not waiting on us. Calculation reads take no row lock. A payment batch locks a payslip alone with LockPayslip (see Payment batches) payroll/run.go
Payroll run transitions (Phase 5 P1: submit, approve, reject, revise, void) in a one-member organization organization FOR UPDATE (workflow.LockSoleOwner), else nothing → organization FOR KEY SHARE → the actor’s organization_member row FOR KEY SHARE → payroll_run FOR UPDATE (lockRun) → submit: calculate as Recalculate (inserts, then SetAmount audits) → approve / void: the current generation’s payslip rows FOR UPDATE in id order (lockPayslips, current calc_no only) → approve: the payees’ organization_member rows FOR KEY SHARE in id order (each payslip instance’s reporter foreign key; taken before anything audits because workflow.Transition and each payslip’s workflow.Start audit and Start then inserts rows keying them, so an HR path holding a member FOR UPDATE and queued for the audit lock would otherwise deadlock with it) → void check (reads payslip_payment and the live payment_batch_line rows without a lock: a batch locks the same payslip rows FOR UPDATE before it adds or pays a line, so the void either sees that line or makes the batch wait and then refuse the payslip, its run no longer approved) → workflow.Transition (instance FOR UPDATE, decision, Poster, audit) → approve: one workflow.Start per payslip with net pay (audits) + UPDATE payslip SET instance_id (the row is held; the new instance row is ours) → UPDATE payroll_run → audit. The employee rows are not locked: no insert on this path keys them. The actor’s organization_member row is key-shared at the top, beside organization (a submit audits in SetAmount before workflow.Transition‘s decision insert key-shares it; final review M-2), so apart from the payees’ rows pre-locked before the transition, no row lock is taken after the first audit.Write except the held rows and rows this transaction inserted. Submit has the same shape as Recalculate (its audit is SetAmount’s, after the inserts) payroll/transition.go
e-Invoice settings einvoice_setting FOR UPDATE → company_profile FOR UPDATE → company.Update → upsert → audit einvoice/setting.go UpdateSetting
Sales workflow publish (in scope) workflow_draft FOR UPDATE → einvoice_setting FOR SHARE → re-read; the switch-on reads graphs without locking them einvoice/setting.go keepsVoidInScope
Company profile company_profile FOR UPDATE (GetForUpdate) first → unlocked reads of journal_entry (on a base-currency or fiscal-year change) and of tax_registration and live wht_deduction (on a base-currency change) (X5: a payment’s, remittance’s or registration’s own company_profile FOR SHARE/FOR UPDATE conflicts with it, so the check is serialized) → the profile write → audit. Callers that already hold the profile (SST registration, e-Invoice settings) re-take it as a no-op (D3 I-4) company/company.go Update
SST registration tax_registration FOR UPDATE → company_profile FOR UPDATE → write → audit taxreturn/registration.go PutRegistration
SST returns and payments return paths: return header FOR UPDATE → registration FOR SHARE (Finalise only) → the latest finalised return FOR UPDATE (amendment Finalise) → the ledger’s post/reverse of the imported-services accrual (journal number rows, fix round 1 I-3). Payment paths start, in a one-member organization, organization FOR UPDATE (workflow.LockSoleOwner, owner confirmation D1), at tax_return_payment FOR UPDATE → the period’s latest finalised return FOR UPDATE. Both then → numbering.Allocate → workflow.Transition → audit taxreturn/return.go, taxreturn/payment.go
HR lifecycle (Phase 5 H1) organization FOR UPDATE (always, Ruling H1-14: it is also the sole-owner exception’s lock and the row member.BumpPermVersion updates) → employee FOR UPDATE → organization_member FOR UPDATE → a transfer’s target org_position FOR NO KEY UPDATE (the seat, Phase 5 H2) → member_position rows FOR UPDATE in id order (orgstruct.LockAssignmentsFrom) → a hire naming a vacancy: the vacancy row FOR UPDATE, taken by employee_event_vacancy_guard between the employee lock and the employee_event insert (H2) → employee_event/lifecycle_override inserts → member.Deactivate (the member row again, the organization row’s version) → assignment closes and withdrawals (rows already held) → audit last. member.Deactivate takes organization FOR UPDATE first itself (a no-op when HR already holds it). The employees import takes organization FOR UPDATE before its first row hr/events.go lockEmployee, Transfer; hr/leaving.go RecordLastDay, lockLeaving, apply; hr/job.go ApplyLeaving; identity/member Deactivate; imports/employees.go
Seat assignment (Phase 5 H2) organization_member FOR SHARE → org_position FOR NO KEY UPDATE (taken before requireActiveSeat’s FOR SHARE, so two assignments to one seat queue instead of deadlocking on a lock upgrade; every assignment to a seat is serialised, acting ones too) → insert, where 00822’s headcount trigger re-takes the seat (already held) → audit orgstruct/assignment.go Assign; db/migrations/00822_headcount.sql
HR headcount and vacancies (Phase 5 H2) override grant: organization FOR UPDATE → org_position FOR NO KEY UPDATE (orgstruct.LockSeat) → insert → audit. orgstruct.SetHeadcount: the seat FOR NO KEY UPDATE → update → audit. Vacancy transition: in a one-member organization organization FOR UPDATE (workflow.LockSoleOwner) → vacancy FOR UPDATE → org_position FOR NO KEY UPDATE when the move approves a new vacancy → workflow.Transition → vacancy.headcount_raised_at → orgstruct.SetHeadcount (seat already held) → audit. CloseVacancy: vacancy FOR UPDATE → audit. A hire naming a vacancy: see the HR lifecycle row hr/headcount.go GrantHeadcountOverride; orgstruct/headcount.go LockSeat, SetHeadcount; hr/vacancy.go TransitionVacancy, CloseVacancy; db/migrations/00823_vacancy.sql
Leave (Phase 5 H3) Requests, transitions, accruals and adjustments: organization FOR UPDATE → employee FOR UPDATE (H1’s lockEmployee, which takes the organization first) → an attachment’s document FOR UPDATE (documents.Lock, inside lockEmployee’s scope, in RequestLeave) → workflow_instance (inside workflow.Transition; the sole-owner exception’s organization lock is already held) → leave_request / leave_ledger inserts → audit last. SetWorkCalendar and SetLeaveSetting take organization FOR UPDATE first; AddHoliday, SetLeaveRules, CreateLeaveType and UpdateLeaveType do not, because none reads a “before” a concurrent writer could share: UpdateLeaveType locks its own leave_type row, AddHoliday relies on a unique index, and SetLeaveRules and CreateLeaveType are inserts (a decision left open, not a proof of safety). The routing job (RecordRoutingMoves): its resolve phase (workflow.ReroutedOn) takes no row lock; it inserts leave_routing_move ON CONFLICT DO NOTHING, and the audit-chain advisory lock is taken only in the final audit loop, after every insert. Known: leave_type FOR UPDATE blocks request and grant foreign-key key-share; FOR NO KEY UPDATE is the follow-up hr/leave_request.go RequestLeave, TransitionLeave; hr/leave_ledger.go AccrueYear, Adjust; hr/leave_calendar.go SetWorkCalendar, SetLeaveSetting, AddHoliday; hr/leave_types.go UpdateLeaveType; hr/leave_routing.go RecordRoutingMoves
Trade documents (Phase 4 O1) Transition: in a one-member organization, organization FOR UPDATE (workflow.LockSoleOwner, Ruling O1-18) → header FOR UPDATE → on a cancel, the document’s live lines FOR UPDATE in id order → workflow.Transition → audit. Manual close: header FOR UPDATE → live lines FOR UPDATE in id order → update → audit. A child line’s insert takes its source line FOR UPDATE (00701’s trade_line_guard) and never the source header, so a cancel or close holding the source’s lines makes the child wait and then see the new state; no cycle. Create: number → workflow.Start → header → lines → audit. Update: header FOR UPDATE → header → lines → audit trade/transition.go Transition, trade/chain.go Close, trade/document.go, migration 00701
Inventory stock documents (receipts, deliveries, returns, adjustments; Phase 4 O2) in a one-member organization, organization FOR UPDATE (workflow.LockSoleOwner, R-O2-13; the audit carries workflow.SoleOwnerAudit) → trade_document FOR UPDATE → on a cancel, its live lines FOR UPDATE in id order → company_profile FOR SHARE (the costing method; company.SetCostingMethod takes it FOR UPDATE) → item_cost rows FOR UPDATE in item id order, created if missing → cost_layer rows FOR UPDATE in seq order (FIFO) → stock_balance (stock_move’s trigger: a zero row ON CONFLICT DO NOTHING, then an UPDATE) → workflow.Transition (the Poster reads the moves) → audit, last. adjustment.Create writes its detail, direction and lot rows after trade.Create’s audit: INSERTs whose only locks are FOR KEY SHARE on rows the transaction wrote or that nothing locks FOR UPDATE inventory/apply.go Transition, inventory/costing.go beginStock, inventory/adjustment/adjustment.go Create
Inventory transfers (Phase 4 O2) company_profile FOR SHARE → the item’s item_cost FOR UPDATE → stock_balance (trigger) → audit inventory/transfer.go TransferStock
Trade stock documents through O3 (receipts, deliveries, returns) in a one-member organization, organization FOR UPDATE (workflow.LockSoleOwner) → trade_document FOR UPDATE → on a cancel or rejection, its live trade_line rows FOR UPDATE in id order → the approver and invoiced checks, reading workflow_decision and the AP/AR invoice lines without locks → O2’s inventory.Transition order → the sole-owner audit row, last. 00740’s trigger reads the decision’s order rows without locks trade/stock.go TransitionStock
Purchase invoice approval, three-way match (O3) purchase_invoice FOR UPDATE → the matched receipt and return trade_line rows FOR UPDATE in id order → purchase_match_tolerance read → the override row and its audit → workflow.Transition (the accrual reads receipt_valuation) → audit, last. A receipt’s cancel locks its header then its lines and never an invoice, so no cycle. A purchase return drafted on several receipt lines locks them in line order (O1’s guard); an approval locking the same lines in id order may deadlock with it and Postgres aborts one — retry payables/transition.go TransitionInvoice, payables/match.go checkMatch
Sales invoice issue on deliveries (O3) organization (LockSoleOwner) → sales_invoice FOR UPDATE → a credit note’s original FOR UPDATE → the delivery and sales-return trade_line rows FOR UPDATE in id order → issue → workflow.Transition → audit receivables/transition.go TransitionDocument, receivables/deliverymatch.go
Depreciation run (Phase 4 O4) the org’s asset rows in service by the period’s end FOR UPDATE in id order (before reading open disposals and posted sums, Ruling FA-27) → depreciation_run insert → ledger.Post → audit, last. No advisory lock; a second run of the same period fails on depreciation_run_once_per_period assets/run.go RunDepreciation
Consolidation runs (Phase 6 stage 2) company_group FOR UPDATE first (serialises the runs of one group, and the retained-earnings roll-forward looks at the previous run) → the group’s register, the current inputs, the scope and the consent checks (group.ReadAllowed: advisory lock class 1 on the member’s audit chain, hashtext(member), taken inside group_read_allowed and group_cross_read_audit, one member after another in scope order) → the member reads (group_member_*, each taking the member’s class-1 lock again, a re-entrant take in the same transaction) → rates (fx, reads) → the run’s inserts → audit.Write on the OWNER’s chain last (class 1, key hashtext(owner)). The cross-organization audit rows take the member’s class-1 lock, then the owner’s audit.Write takes the owner’s: two different keys, always in that order inside a run, and a member’s own write takes only its own key, so no cycle. Run events and input approvals take no lock beyond their insert (the tables are insert-only; races are settled by unique indexes and the event guard) → audit. A member’s MapCounterparty takes no lock → audit consolidation/build.go CreateRun, consolidation/reads.go, consolidation/events.go; db/migrations/00942_group_member_reads.sql
Asset disposals (Phase 4 O4) create: the asset row FOR UPDATE → asset_disposal insert → workflow.Start → audit. Transition: organization FOR UPDATE (workflow.LockSoleOwner) → asset_disposal FOR UPDATE → workflow.Transition (instance, then the guard’s asset row FOR UPDATE) → the Poster → audit, last (with workflow.SoleOwnerAudit). A disposal approved while a run holds the asset waits, then sees the run’s lines assets/disposal.go CreateDisposal, TransitionDisposal
Webhook delivery (public API W2) the webhook_delivery row FOR UPDATE OF d → the webhook_endpoint row FOR UPDATE (to count live attempts for the per-endpoint cap) → the delivery’s attempt_started_at → commit. No lock is held across the HTTP call: it runs between two transactions. The post-call transaction takes the delivery row, then updates the endpoint’s health, then audits last (auto-disable). No advisory lock webhooks/deliver.go claim, record, countDead
Webhook replay and backfill (public API W3) the delivery row (or the endpoint’s dead rows in created_at, id order) FOR UPDATE → its reset and job insert → audit, last. Fan-out by the outbox recorder reads webhook_endpoint without a lock, inside the producing transaction, after that transaction’s own locks webhooks/replay.go, webhooks/recorder.go
API key rotate, revoke, scopes (public API A2) the api_key row FOR UPDATE → a rotation’s successor INSERT and the predecessor’s expires_at UPDATE → audit, last (which also writes the audit_log_credential link) identity/apikey/apikey.go
Chart tree moves (structured account codes) advisory lock class 4, hashtext(org_id) (chart.lockAccountTree, registered in kernel/audit/audit.go) → the moved account and its parent read, then the subtree UPDATE and the new parent’s header flip (rows only touched, no FOR UPDATE) → audit last. CreateAccount takes no tree lock: seeding audits an account and then creates the next, so a per-create lock would be class 4 after class 1. A create racing a move, or two moves racing without the lock, is stopped at commit by the deferred account_tree_coherent trigger (01021), which re-reads the parent accounting/chart/move.go MoveAccount; db/migrations/01021_account_structured_codes.sql
  • organization (one-member organizations only) → everything. The sole-owner exception (Ruling O-3a) re-counts active members after organization FOR UPDATE, which a new member’s INSERT contends with through its foreign-key check (FOR KEY SHARE), and a member made active again through the organization_member_reactivation_lock trigger (00444). Callers that may use it take it first (workflow.LockSoleOwner: AR’s TransitionDocument, the e-Invoice cancel, and owner confirmation D1’s AP payment, WHT remittance and SST payment transitions, supplier bank and payee verification, payment batch approval, file and mark paid, and budget approval; Phase 4’s trade document and stock document transitions); workflow.Transition takes it again before it re-counts, with NOWAIT: already held, that is a no-op. When the caller did not take it first (its unlocked count was not 1, and a concurrent deactivation made it 1 since), a late FOR UPDATE would come after the instance and header locks, the reverse order. NOWAIT never waits, so it cannot close a cycle: a lock that is not free leaves the refusal standing (the exception does not apply).

  • organization → organization_member: role.SetMemberRoles, HR lifecycle and member.Deactivate. SetMemberRoles used to take the member row first and the organization row later (through afterAuthorityChange), the reverse of the other two; it now takes organization FOR UPDATE first, so the inversion is gone (TestSetMemberRolesLocksTheOrganizationFirst). Nothing retries a 40P01: internal/kernel/db has no deadlock handling, so an inversion here would surface as a failed request or job run.

These are the orders that span two lanes, and so appear in neither lane’s own list.

  • AR or AP header → einvoice_submission row. The e-Invoice guard runs inside workflow.Transition, after the caller’s header lock. Retry and cancel take the header themselves (lockSource) before the row.
  • einvoice_setting → company_profile, and tax_registration → company_profile. The e-Invoice settings and the SST registration both write company_profile.sst_registration_no (Ruling SST-R14). Nothing locks the profile before either, so the two cannot deadlock. The settings save reads whether a registration exists without locking it, after it has the profile lock (final review M3). A registration created after that read sets the number itself.
  • Batch → payments → claims → advances → payslips → suppliers and payout profiles (FOR SHARE) → invoices (C4; payslips, payroll P1). A run’s approval or void takes payroll_run and then its payslips in id order; a batch takes the payslips without the run, so the two serialize on the payslip rows. A claim or advance moved on its own takes only its own row, then workflow.Transition, whose payoutHold guard reads the batch lines without a lock; a batch that adds it has already locked that same row, so the two serialize on it.
  • Batch → payments → suppliers (FOR SHARE) → invoices. The AP invoice void and the offset check the batch hold under the invoice lock that ApproveBatch also takes. An approval racing a void either sees the void, or the void sees the approval.
  • A batch or payment payout → WHT deductions. These are the AP payment order above. The payout’s audit comes after every move (batch.go, Ruling B7).