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
| # | Severity | Category | Description |
|---|---|---|---|
| 1 | CRITICAL | Bug Fix | Inverted permission check — Error() fired when user HAD the BW-CS-SERVICE role, blocking all CS users |
| 2 | CRITICAL | Bug Fix | BW-CS-SERVICE permission set referenced but never defined in the extension |
| 3 | HIGH | Bug Fix | Open By User ID field was Text[30]; BC User Name is Text[50] — truncation broke lock release matching |
| 4 | HIGH | Bug Fix | CurrPage.Update(false) called inside OnAfterGetRecord on Service Orders list, creating an infinite render loop |
| 5 | HIGH | Bug Fix | Internal control fields (Check Status, Open By User ID) were visible on the Service Order card |
| 6 | MEDIUM | Bug Fix | Dead code in event subscriber codeunit — RunTrigger assignment had no effect (value parameter, not VAR) |
| 7 | LOW | Compliance | All fields used DataClassification = ToBeClassified; updated to SystemMetadata / EndUserPseudonymousIdentifiers |
| 8 | LOW | Bug Fix | Live Status field missing Caption property — raw field name displayed in UI |
| 9 | HIGH | Bug Fix | Permission check bypassed entirely when user not found in User table — no error raised |
| 10 | MEDIUM | Bug Fix | Unnecessary OnModifyRecord trigger causing page refresh flicker on every record save |
| 11 | NEW | Feature | Stale lock auto-release — locks older than a configurable threshold are automatically cleared on page open |
| 12 | NEW | Feature | Release Lock action — SUPER users can manually force-release a stale lock and re-acquire it |
| 13 | NEW | Feature | Configuration page (Service Order Lockout Setup) — configurable timeout and master on/off toggle |
| 14 | NEW | Feature | Upgrade codeunit — clears all existing locks during v1.0 → v1.1 migration |
| 15 | CLEANUP | Cleanup | Removed dead event subscriber codeunit (event2), unused variables, and old v1.0.0.0 subfolder |
| 16 | CLEANUP | Cleanup | Reorganized files into object-type subfolders; renamed files to follow repo naming conventions |
| 17 | CLEANUP | Cleanup | Added brief and description metadata to app.json for BC admin console visibility |
2.2 Independent Review Findings
| # | Severity | Category | Description |
|---|---|---|---|
| 18 | HIGH | Bug Fix | Upgrade codeunit ran on every upgrade — would wipe active locks on future version bumps beyond 1.1 |
| 19 | HIGH | Bug Fix | Record navigation (Next/Previous) on Service Order card left previous record locked and didn't lock or check the new record |
| 20 | HIGH | Bug Fix | SUPER users without explicit BW-CS-SERVICE assignment were blocked from opening Service Orders, making the Release Lock action unreachable |
| 21 | MEDIUM | Bug Fix | GetSetup() could throw a primary key violation if two users triggered singleton initialization concurrently |
| 22 | MEDIUM | Bug Fix | Live Status was a persistent table field used only for display — stale persisted values could mislead external consumers (APIs, reports) |
| 23 | LOW | Compliance | idRanges upper bound was 999999 (947,600 IDs reserved for 7 objects) — tightened to 52499 |
| 24 | LOW | Cleanup | Variable recuser renamed to RecUser for PascalCase consistency with other variables in the same file |
| 25 | LOW | Bug Fix | Permission 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 StatusandLive Status→SystemMetadata(internal control flags)Open By User ID→EndUserPseudonymousIdentifiers(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:
| Setting | Default | Range | Purpose |
|---|---|---|---|
| Lockout Enabled | true | on / off | Master 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) | 4 | 1 – 72 | Hours 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 / Folder | Reason |
|---|---|
COD500~1.AL | Empty 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:
| Original | New Location |
|---|---|
Tab-Ext50000Service_Header..al | TableExt/ServiceHeader.TableExt.al |
Pag-Ext50000.ServiceOrders.al | PageExt/ServiceOrders.PageExt.al |
Pag-Ext50001.ServiceOrderCard.al | PageExt/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 togglesCurrPage.EditableaccordinglyReleasePreviousLock()— releases the previously-locked record using a separatePrevHeaderrecord variable, identified by trackedLockedDocType/LockedDocNopage variablesTrackLockedRecord()/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 Type | ID | Name | Status |
|---|---|---|---|
| Table Extension | 52400 | ServiceHeader (extends Service Header) | Modified |
| Table | 52401 | Service Order Lockout Setup | New |
| Page Extension | 52400 | ServiceOrders (extends Service Orders) | Modified |
| Page Extension | 52401 | ServiceOrder (extends Service Order) | Modified |
| Page | 52402 | Service Order Lockout Setup | New |
| Permission Set | 52401 | BW-CS-SERVICE | New |
| Codeunit | 52402 | Service Order Lockout Upgrade | New |
| Codeunit | 52400 | event2 (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-SERVICEpermission 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-SERVICEcan 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.