Skip to main content

Change Document — Service Order Lockout v1.1.0.0

v1.0.0.56 → v1.1.0.0 Bestway BC Development | February 27, 2026 Branch: fix/service-order-lockout-defects


1. Background

The Service Order Lockout extension (v1.0.0.56) was delivered by Sataware Technologies to prevent concurrent editing of Service Orders in Business Central. A pre-UAT code review against the original requirements document identified 2 critical defects, 3 high-severity issues, and 3 medium/low items that collectively rendered the extension non-functional and unsuitable for user acceptance testing.

An independent review of the initial fix implementation identified 4 additional bugs, 1 edge case, and 4 pattern/compliance items. All actionable findings were resolved in the same branch.

This document covers all modifications made on the fix/service-order-lockout-defects branch to resolve those defects, plus new capabilities added to make the extension production-ready: stale lock cleanup, a manager lock override, and a configuration page.


2. Summary of Changes

2.1 Original Review Findings

#SeverityCategoryDescription
1CRITICALBug FixInverted permission check — Error() fired when user HAD the BW-CS-SERVICE role, blocking all CS users
2CRITICALBug FixBW-CS-SERVICE permission set referenced but never defined in the extension
3HIGHBug FixOpen By User ID field was Text[30]; BC User Name is Text[50] — truncation broke lock release matching
4HIGHBug FixCurrPage.Update(false) called inside OnAfterGetRecord on Service Orders list, creating an infinite render loop
5HIGHBug FixInternal control fields (Check Status, Open By User ID) were visible on the Service Order card
6MEDIUMBug FixDead code in event subscriber codeunit — RunTrigger assignment had no effect (value parameter, not VAR)
7LOWComplianceAll fields used DataClassification = ToBeClassified; updated to SystemMetadata / EndUserPseudonymousIdentifiers
8LOWBug FixLive Status field missing Caption property — raw field name displayed in UI
9HIGHBug FixPermission check bypassed entirely when user not found in User table — no error raised
10MEDIUMBug FixUnnecessary OnModifyRecord trigger causing page refresh flicker on every record save
11NEWFeatureStale lock auto-release — locks older than a configurable threshold are automatically cleared on page open
12NEWFeatureRelease Lock action — SUPER users can manually force-release a stale lock and re-acquire it
13NEWFeatureConfiguration page (Service Order Lockout Setup) — configurable timeout and master on/off toggle
14NEWFeatureUpgrade codeunit — clears all existing locks during v1.0 → v1.1 migration
15CLEANUPCleanupRemoved dead event subscriber codeunit (event2), unused variables, and old v1.0.0.0 subfolder
16CLEANUPCleanupReorganized files into object-type subfolders; renamed files to follow repo naming conventions
17CLEANUPCleanupAdded brief and description metadata to app.json for BC admin console visibility

2.2 Independent Review Findings

#SeverityCategoryDescription
18HIGHBug FixUpgrade codeunit ran on every upgrade — would wipe active locks on future version bumps beyond 1.1
19HIGHBug FixRecord navigation (Next/Previous) on Service Order card left previous record locked and didn't lock or check the new record
20HIGHBug FixSUPER users without explicit BW-CS-SERVICE assignment were blocked from opening Service Orders, making the Release Lock action unreachable
21MEDIUMBug FixGetSetup() could throw a primary key violation if two users triggered singleton initialization concurrently
22MEDIUMBug FixLive Status was a persistent table field used only for display — stale persisted values could mislead external consumers (APIs, reports)
23LOWComplianceidRanges upper bound was 999999 (947,600 IDs reserved for 7 objects) — tightened to 52499
24LOWCleanupVariable recuser renamed to RecUser for PascalCase consistency with other variables in the same file
25LOWBug FixPermission error message didn't name the required permission set — admins couldn't self-diagnose the fix

3. Detailed Changes

3.1 Critical Defect Fixes

CRITICAL-01: Inverted Permission Check

File: PageExt/ServiceOrder.PageExt.al (page extension 52401, OnOpenPage trigger)

The original OnOpenPage trigger checked the Access Control table for the BW-CS-SERVICE role and called Error() when FindFirst() returned true — meaning it blocked the exact users who were supposed to have access. Every Customer Service representative with the correct permission set was locked out, while users without the role could access Service Orders freely.

The condition was inverted so Error() fires when the user does NOT have the BW-CS-SERVICE role. Users with the SUPER role are also permitted through, even without explicit BW-CS-SERVICE assignment (see item 20). The error message was updated to name the specific permission set: 'You need the BW-CS-SERVICE permission set to access Service Orders. Contact your system administrator to request access.'

CRITICAL-02: Undefined Permission Set

File: PermissionSet/BWCSService.PermissionSet.al (NEW — permissionset 52401)

The extension referenced a permission set called BW-CS-SERVICE in its access control logic, but this permission set was never defined anywhere in the extension. Without it, the AccessCtrl.FindFirst() check could never return true, effectively locking out all users. A new permissionset object (ID 52401) was created with Assignable = true, granting Read/Modify access to Service Header table data and Read access to the new Service Order Lockout Setup table.


3.2 High-Severity Fixes

HIGH-01: Field Length Mismatch

File: TableExt/ServiceHeader.TableExt.al (field 50049, Open By User ID)

The Open By User ID field was defined as Text[30], but Business Central's User Name field is Text[50]. If a user's ID exceeded 30 characters, the stored value would be silently truncated. When OnClosePage compared Rec."Open By User ID" to UserId to decide whether to release the lock, the truncated value would never match — leaving the Service Order permanently locked with no way to release it short of direct database intervention. Changed to Text[50].

HIGH-02: Render Loop in Service Orders List

File: PageExt/ServiceOrders.PageExt.al (page extension 52400, OnAfterGetRecord)

The original OnAfterGetRecord trigger called CurrPage.Update(false) after setting the Live Status value. Since CurrPage.Update() re-triggers OnAfterGetRecord, this created an infinite render loop that could degrade list page performance or cause the page to hang. The CurrPage.Update() calls were removed. The Live Status column now displays from a page-level Text variable computed in OnAfterGetRecord (see item 22).

HIGH-03: Internal Fields Exposed on Card

File: PageExt/ServiceOrder.PageExt.al (page extension 52401, layout)

The Check Status (Boolean) and Open By User ID fields were rendered directly on the Service Order card before the Customer No. field. These are internal control fields used by the locking mechanism and should not be visible to end users. Both fields were set to Visible = false.

HIGH-04: Permission Check Bypass

File: PageExt/ServiceOrder.PageExt.al (page extension 52401, OnOpenPage)

The original permission check was wrapped in a nested if-then: it only ran the role check when recuser.FindFirst() returned true (i.e., when the user was found in the User table). If the user was NOT found, the entire permission check was silently skipped, allowing an unrecognized user to bypass the BW-CS-SERVICE gate. The logic was flattened so that a missing user record now raises an explicit error: 'User %1 was not found. Contact your system administrator.'.


3.3 Medium / Low Fixes

MEDIUM-01: Dead Code in Event Subscriber

File: COD500~1.AL (codeunit 52400, event2) — DELETED

The event subscriber codeunit contained a single line: RunTrigger := true. However, RunTrigger is a value parameter (not VAR), so the assignment had no effect — it was dead code. The codeunit also had an unused PageMgt variable, was poorly named (event2), and fired on every Service Header modification for zero benefit. The entire codeunit was removed.

MEDIUM-02: Unnecessary OnModifyRecord Trigger

File: PageExt/ServiceOrder.PageExt.al (page extension 52401)

The OnModifyRecord trigger called CurrPage.Update(false) on every record save, forcing a page refresh that could cause UI flickering. BC naturally refreshes the page after modifications, making this trigger unnecessary. It was removed.

LOW-01: DataClassification

File: TableExt/ServiceHeader.TableExt.al

All three original fields used DataClassification = ToBeClassified, which is a placeholder value that Microsoft flags during compliance audits. Each field was updated to the appropriate classification:

  • Check Status and Live StatusSystemMetadata (internal control flags)
  • Open By User IDEndUserPseudonymousIdentifiers (stores a BC User ID that can be linked back to a person)
  • Locked At (new) → SystemMetadata

LOW-02: Missing Caption on Live Status

File: TableExt/ServiceHeader.TableExt.al (fields 50048, 50049)

The Live Status field had no Caption property, causing BC to display the raw field name in the UI. A Caption = 'Live Status' was added. The Open By User ID field was also missing a caption and was corrected.


3.4 New Features

Stale Lock Auto-Release

Files: PageExt/ServiceOrder.PageExt.al, TableExt/ServiceHeader.TableExt.al

The original implementation had no mechanism to handle abandoned locks. If a user's session ended unexpectedly — browser crash, session timeout, navigating away via URL — OnClosePage never fired and the Service Order remained locked permanently. This was a design gap in the original v1.0 implementation, not something introduced by our changes.

A new DateTime field (Locked At, field 50050) records when each lock was acquired. On page open, if a lock exists and its age exceeds the configurable threshold (default: 4 hours), the lock is automatically cleared and re-acquired by the current user. Locks with no timestamp (pre-v1.1 records) are treated as stale.

Release Lock Action (Manager Override)

File: PageExt/ServiceOrder.PageExt.al (action ReleaseLock)

A "Release Lock" action was added to the Processing group on the Service Order card. It is only enabled when a lock exists (Enabled = Rec."Check Status") and requires the SUPER role to execute. The action prompts for confirmation, clears the existing lock, re-acquires it for the current user, and re-enables page editing. This provides a way for supervisors to resolve stale locks without waiting for the auto-release threshold.

Configuration Page

Files: Table/ServiceOrderLockoutSetup.Table.al, Page/ServiceOrderLockoutSetup.Page.al

A new setup table (52401) and card page (52402) provide two configurable settings:

SettingDefaultRangePurpose
Lockout Enabledtrueon / offMaster toggle. When disabled, all locking logic is bypassed — no locks are acquired, the Live Status column is hidden on the list, and all users can edit freely. Useful during bulk data operations or migrations.
Stale Lock Timeout (Hours)41 – 72Hours before an unreleased lock is considered stale and auto-cleared.

The setup page is accessible via BC search under Administration: "Service Order Lockout Setup". The table uses a singleton pattern (single record, auto-initialized on first access via GetSetup()). Both page extensions read from this table on page open.

Upgrade Codeunit

File: Codeunit/ServiceOrderLockoutUpgrade.Codeunit.al (codeunit 52402)

A Subtype = Upgrade codeunit runs automatically when the extension is upgraded. The OnUpgradePerCompany trigger checks ModuleInfo.DataVersion and only runs the stale lock cleanup when upgrading from a version below 1.1.0.0. This ensures no stale locks from the buggy v1.0 version persist after upgrade, while preventing future upgrades (1.1→1.2, etc.) from wiping legitimate active locks.


3.5 Cleanup and Repo Hygiene

Removed Files

File / FolderReason
COD500~1.ALEmpty event subscriber with dead code — removed entirely
Sataware_Tech_Serviceorder_Status/Old v1.0.0.0 source, compiled .app files, and bundled Microsoft .alpackages (~60 MB). Superseded by the current v1.1 source.

File Reorganization

All AL source files were moved from the extension root into object-type subfolders and renamed to follow the repository's naming conventions:

OriginalNew Location
Tab-Ext50000Service_Header..alTableExt/ServiceHeader.TableExt.al
Pag-Ext50000.ServiceOrders.alPageExt/ServiceOrders.PageExt.al
Pag-Ext50001.ServiceOrderCard.alPageExt/ServiceOrder.PageExt.al
COD500~1.AL(deleted)

Unused Variables Removed

The Service Order card page extension declared four variables of which only two were used. Removed: AggPerSet (Record "Aggregate Permission Set"), usercard (Page "User Card"), and a commented-out Role (Record "All Profile"). Retained: AccessCtrl and RecUser.

idRanges Tightened

The idRanges in app.json was changed from 52400–999999 to 52400–52499. The original range reserved 947,600 IDs for an extension with 7 objects. The tightened range provides 100 IDs — more than sufficient — and reduces collision risk with other per-tenant extensions.

Note: table extension field IDs (50047–50050) are outside this range. idRanges governs object IDs (tables, pages, codeunits, etc.), not field IDs within table extensions. Fields 50047–50049 were deployed in v1.0 at those IDs and cannot be renumbered without a data migration. Field 50050 (Locked At) follows the existing sequence for consistency.


3.6 Independent Review Fixes

An independent code review was performed in a separate session against the uncommitted diff after the initial fixes were implemented. The review identified 4 bugs, 1 accepted edge case, and 4 pattern/compliance items. This section covers the bugs and improvements that were resolved. The accepted edge case is documented in Section 6 (Known Limitations).

REVIEW-01: Upgrade Codeunit Missing Version Guard

File: Codeunit/ServiceOrderLockoutUpgrade.Codeunit.al

The OnUpgradePerCompany trigger originally ran its stale lock cleanup unconditionally on every upgrade. If the extension were later upgraded from v1.1 to v1.2, it would wipe all active locks — including legitimate ones held by users actively editing orders. Added a NavApp.GetCurrentModuleInfo() check so ClearAllStaleLocks() only executes when upgrading from a DataVersion below 1.1.0.0.

REVIEW-02: Record Navigation Broke Locking

File: PageExt/ServiceOrder.PageExt.al

The original fix placed all lock acquire/check logic in OnOpenPage, which fires once when the page opens. If a user navigated to a different Service Order via Next/Previous record (standard BC card page behavior), OnOpenPage did not re-fire. The previous record stayed locked, the new record was neither locked nor checked, and on page close only the last-viewed record was evaluated for release.

The lock logic was moved to OnAfterGetCurrRecord, which fires on page open and on every record navigation. Supporting procedures were added:

  • HandleCurrentRecordLock() — acquires, checks, or enters view-only mode for the current record, and toggles CurrPage.Editable accordingly
  • ReleasePreviousLock() — releases the previously-locked record using a separate PrevHeader record variable, identified by tracked LockedDocType / LockedDocNo page variables
  • TrackLockedRecord() / ClearLockedRecord() — bookkeeping helpers for the tracked key

OnOpenPage now only handles setup loading and the permission check. OnClosePage calls ReleasePreviousLock() to release whatever record is currently held.

REVIEW-03: SUPER Users Blocked From Page

File: PageExt/ServiceOrder.PageExt.al (OnOpenPage)

The BW-CS-SERVICE permission check blocked all users without that specific role — including SUPER users. Since the Release Lock action requires the SUPER role, a SUPER administrator couldn't even open the page to use it. The permission check now falls through to a UserHasSuperRole() check before raising the error, allowing SUPER users to access the page without explicit BW-CS-SERVICE assignment.

REVIEW-04: GetSetup() Concurrent Initialization Race

File: Table/ServiceOrderLockoutSetup.Table.al

The GetSetup() procedure used the pattern if not Get() then begin Init(); Insert(); end;. If two users triggered setup initialization concurrently (e.g., two users opening the Service Orders list for the first time after deployment), both sessions would execute Init(); Insert(); and the second Insert() would throw a primary key violation. Changed to the standard BC singleton pattern: if not Insert() then Get(), which gracefully handles the race by falling back to a read if another session inserted first.

REVIEW-05: Live Status Converted to Page Variable

File: PageExt/ServiceOrders.PageExt.al

The Live Status column on the Service Orders list was bound to Rec."Live Status" — a persistent Option field on the Service Header table. The value was computed in OnAfterGetRecord but never persisted via Rec.Modify(), which is the correct behavior for a display-only indicator. However, binding to a persistent field created two risks: (1) any other code path that saves the record could accidentally persist a stale computed value, and (2) external consumers reading the field directly (APIs, reports, OData) would see the default empty value rather than the current lock state.

The list page column was rebound to a Text[20] page variable (LiveStatusText) computed fresh in OnAfterGetRecord. The Live Status field remains on the table extension (removing a deployed field requires a data migration) but is no longer read or written by any code in the extension.

REVIEW-06: Permission Error Message Made Actionable

File: PageExt/ServiceOrder.PageExt.al (OnOpenPage)

The permission error message was changed from 'You do not have permission to access Service Orders.' to 'You need the BW-CS-SERVICE permission set to access Service Orders. Contact your system administrator to request access.'. The original message required a support call or developer investigation to determine the fix. The new message names the exact permission set, enabling an administrator to resolve the issue immediately.

REVIEW-07: Variable Naming Consistency

File: PageExt/ServiceOrder.PageExt.al

The page variable recuser used lowercase camelCase while all other variables in the same file (AccessCtrl, LockoutSetup, SetupLoaded, LockoutActive) used PascalCase. Renamed to RecUser for consistency.

Deferred: Namespace Declaration

Adding namespace declarations to all AL files was evaluated and deferred. The extension targets application: 25.0.0.0, and Microsoft began namespacing their base application objects in BC 25.0. Adding a namespace to this extension would require using directives for every Microsoft object referenced (Service Header, Access Control, User, etc.) with exact namespace paths that can only be verified against downloaded symbols. This change is better performed in VS Code with AL: Download Symbols and IntelliSense available to resolve the correct using statements.


4. Object Inventory (v1.1.0.0)

All objects use the extension's declared ID range (52400–52499).

Object TypeIDNameStatus
Table Extension52400ServiceHeader (extends Service Header)Modified
Table52401Service Order Lockout SetupNew
Page Extension52400ServiceOrders (extends Service Orders)Modified
Page Extension52401ServiceOrder (extends Service Order)Modified
Page52402Service Order Lockout SetupNew
Permission Set52401BW-CS-SERVICENew
Codeunit52402Service Order Lockout UpgradeNew
Codeunit52400event2 (Service Order Lockout Subscribers)Deleted

5. Final File Structure

Service Order Lockout/
├── app.json
├── Codeunit/
│ └── ServiceOrderLockoutUpgrade.Codeunit.al
├── Page/
│ └── ServiceOrderLockoutSetup.Page.al
├── PageExt/
│ ├── ServiceOrder.PageExt.al
│ └── ServiceOrders.PageExt.al
├── PermissionSet/
│ └── BWCSService.PermissionSet.al
├── Table/
│ └── ServiceOrderLockoutSetup.Table.al
├── TableExt/
│ └── ServiceHeader.TableExt.al
└── docs/
├── CHANGELOG.md
├── CHANGE-v1.1.0.0.md
└── UAT Test Plan - Service Order Lockout v1.1.0.0.docx

6. Known Limitations

Sub-second race on concurrent lock acquisition

If two users open the same unlocked Service Order within the same sub-second window, both sessions read Check Status = false before either writes the lock. Both call AcquireLock() and the last Modify wins — the first user's lock is silently overwritten, leaving two users in edit mode on the same order.

Why this is accepted:

  • The race window is microseconds. Two users must open the exact same order (not just any order — the same Document Type + No.) at the same instant. In a CS team of 10–20 people working across hundreds of orders, this is a near-zero probability event.
  • The worst-case outcome is the pre-extension status quo: two users editing the same order concurrently. No data loss or corruption occurs.
  • The stale lock timeout provides a backstop — any phantom lock auto-clears within the configured window.

Why we didn't fix it:

The mechanically correct solution is Rec.LockTable() before the check-and-write cycle. LockTable() acquires a SQL-level exclusive lock on the entire Service Header table — not just the row. Every other user opening any Service Order card is blocked until the locking transaction commits. Under normal conditions the lock lasts milliseconds, but if the BC client is slow or the user's network hiccups, all Service Order card opens are serialized through a single-threaded bottleneck. The cure is worse than the disease for a sub-second race whose downside is the status quo.

Locking enforced at the page layer only

The locking mechanism is implemented via page triggers (OnAfterGetCurrRecord, OnClosePage). Any code path that bypasses the Service Order card page — API calls, web services, batch jobs, direct Modify calls from other extensions — will not acquire or respect locks. This is appropriate for the intended use case (preventing CS representatives from stepping on each other's toes in the UI) but does not constitute a data-integrity-level concurrency control.


7. Deployment Notes

Warning: The upgrade codeunit will clear ALL active locks when the extension is upgraded from v1.0 to v1.1. Future upgrades (v1.1+) will not clear locks. Coordinate the initial upgrade timing to minimize impact on users with open Service Orders.

Pre-deployment:

  • Assign the BW-CS-SERVICE permission set to all Customer Service users who need Service Order access. Users without this permission set (and without SUPER) will be blocked from opening Service Orders when lockout is enabled. The error message names the specific permission set to aid troubleshooting.
  • Verify the Lockout Enabled and Stale Lock Timeout settings on the Service Order Lockout Setup page after deployment
  • Communicate to CS team that any in-progress work should be saved before the upgrade window

Post-deployment verification:

  • Confirm the Service Order Lockout Setup page is accessible and shows default values (Enabled = true, Timeout = 4 hours)
  • Test that a CS user with BW-CS-SERVICE can open and lock a Service Order
  • Test that a second user sees the read-only message and View Only mode
  • Test that navigating between records via Next/Previous correctly releases the previous lock and acquires a new one
  • Test that a SUPER user (without BW-CS-SERVICE) can open the page and use the Release Lock action
  • Verify the Live Status column appears on the Service Orders list

8. Testing

The UAT Test Plan has been updated for v1.1.0.0 and is located at docs/UAT Test Plan - Service Order Lockout v1.1.0.0.docx. The plan covers all v1.1 features including stale lock auto-release, the Release Lock action, the configuration page, the lockout enabled/disabled toggle, and record navigation lock handling across 12 test areas and 36 test cases.