Sync knowledge articles with review agent instructions

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Jesper Schulz-Wedde 2026-05-05 14:08:32 +02:00
parent f562fba837
commit 5bcdc55df9
62 changed files with 768 additions and 58 deletions

View file

@ -11,16 +11,16 @@ application-area: [all]
## Description ## Description
Every field on every AL table and table extension must carry an explicit `DataClassification` property. The value drives GDPR tooling, data-subject requests, retention policies, and audit reporting — all of which rely on the field metadata to know what data to include, anonymize, or delete. A field with no `DataClassification` defaults to `ToBeClassified`, which is a compliance gap, not a neutral state. Every field on every AL table and table extension must have a resolved `DataClassification` value, either declared directly on the field or inherited from a table-level default. The value drives GDPR tooling, data-subject requests, retention policies, and audit reporting — all of which rely on the field metadata to know what data to include, anonymize, or delete. A field with no field-level property and no table-level default resolves to `ToBeClassified`, which is a compliance gap, not a neutral state.
## Best Practice ## Best Practice
Choose the narrowest value that accurately describes the field's content: `EndUserIdentifiableInformation` for data that directly identifies a person, `EndUserPseudonymousIdentifiers` for indirect identifiers, `CustomerContent` for business operational data, `SystemMetadata` for system-generated housekeeping, `AccountData` for tenant/billing, `OrganizationIdentifiableInformation` for organization-level identifiers. When uncertain between two values, pick the stronger protection. Choose the narrowest value that accurately describes the field's content: `EndUserIdentifiableInformation` for data that directly identifies a person, `EndUserPseudonymousIdentifiers` for indirect identifiers, `CustomerContent` for business operational data, `SystemMetadata` for system-generated housekeeping, `AccountData` for tenant/billing, `OrganizationIdentifiableInformation` for organization-level identifiers. Use a table-level default for homogeneous tables, and override individual fields whose content differs from that default. When uncertain between two values, pick the stronger protection.
See sample: `classify-every-field-with-dataclassification.good.al`. See sample: `classify-every-field-with-dataclassification.good.al`.
## Anti Pattern ## Anti Pattern
Leaving `DataClassification = ToBeClassified` on a field, or omitting the property entirely (which resolves to the same default). Code in this state fails compliance audits and breaks the subject-access-request and retention tooling that depends on the property being set correctly. Leaving `DataClassification = ToBeClassified` on a field, omitting classification when the table has no default, or relying on a table-level default that understates a field's actual content. Code in this state fails compliance audits and breaks the subject-access-request and retention tooling that depends on the property being set correctly.
See sample: `classify-every-field-with-dataclassification.bad.al`. See sample: `classify-every-field-with-dataclassification.bad.al`.

View file

@ -0,0 +1,20 @@
codeunit 50930 "Perf Sample Subscriber Good"
{
[EventSubscriber(ObjectType::Table, Database::"Sales Line", 'OnAfterValidateEvent', 'No.', false, false)]
local procedure OnAfterValidateSalesLineNo(var Rec: Record "Sales Line")
var
Item: Record Item;
begin
if Rec.Type <> Rec.Type::Item then
exit;
Item.SetLoadFields("Costing Method");
if Item.Get(Rec."No.") then
if Item."Costing Method" = Item."Costing Method"::Specific then
UpdateSpecificCostingState(Rec);
end;
local procedure UpdateSpecificCostingState(var SalesLine: Record "Sales Line")
begin
end;
}

View file

@ -17,7 +17,9 @@ Event subscribers run synchronously on the publisher's thread. If a subscriber d
## Best Practice ## Best Practice
Keep subscribers small: guard early with inexpensive checks, defer heavy work to a task queue or a background session, and cache results across invocations when the data is stable. Keep subscribers small: guard early with inexpensive checks on the publisher record before doing any database work, defer heavy work to a task queue or a background session, and cache results across invocations when the data is stable. In hot events, a cheap `Type`/`Status`/`IsTemporary` exit before a `Get` or `FindFirst` is often the difference between a rare lookup and an N+1 query across every posted line.
See sample: `keep-event-subscribers-lightweight.good.al`.
## Anti Pattern ## Anti Pattern

View file

@ -2,7 +2,8 @@ codeunit 51204 "Perf Sample LockTable Good"
{ {
procedure GetOrCreate(var AgentStatus: Record "Integer"): Boolean procedure GetOrCreate(var AgentStatus: Record "Integer"): Boolean
begin begin
// Read path: no lock. // Read path: consistent read on this record instance only.
AgentStatus.ReadIsolation := IsolationLevel::ReadCommitted;
if AgentStatus.Get(1) then if AgentStatus.Get(1) then
exit(true); exit(true);

View file

@ -11,11 +11,11 @@ application-area: [all]
## Description ## Description
LockTable takes an exclusive write lock on the affected table for the remainder of the transaction. In a helper that is called from many read-only sites and a few write sites, placing LockTable unconditionally at the top serializes every reader on every other reader's lock — the helper becomes a system-wide contention point. The correct shape is a conditional structure: try the read-only path first, and only fall through to LockTable when the code genuinely needs to modify the table. LockTable causes reads against the table to use update locks for the remainder of the transaction. In a helper that is called from many read-only sites and a few write sites, placing LockTable unconditionally at the top serializes every reader on every other reader's lock — the helper becomes a system-wide contention point. The correct shape is a conditional structure: try the read-only path first, and only fall through to LockTable when the code genuinely needs to modify the table.
## Best Practice ## Best Practice
For paths that are read-only, prefer `ReadIsolation` over `LockTable`. Setting `Rec.ReadIsolation := IsolationLevel::ReadCommitted` on a record variable gives fine-grained, per-instance control over the isolation level without taking an update lock on the table for the rest of the transaction. Use `LockTable` only for paths that genuinely write to the table. For paths that are read-only, prefer `ReadIsolation` over `LockTable`. Setting `Rec.ReadIsolation := IsolationLevel::ReadCommitted` on a record variable gives fine-grained, per-instance control over the isolation level without taking an update lock on the table for the rest of the transaction. Use `ReadCommitted` as the normal read-only choice; move to `RepeatableRead`, `Serializable`, or an update lock only when the code has a concrete consistency invariant that requires it. Use `LockTable` only for paths that genuinely write to the table.
For helpers that may or may not modify records, factor the code so readers return immediately without a lock and only writers reach the LockTable call. A common pattern: attempt `Rec.Get()` first; if it returns the row, exit with the value; otherwise LockTable and proceed with the Insert. Document the pattern in a comment on the helper so callers understand why the LockTable is inside a branch. For helpers that may or may not modify records, factor the code so readers return immediately without a lock and only writers reach the LockTable call. A common pattern: attempt `Rec.Get()` first; if it returns the row, exit with the value; otherwise LockTable and proceed with the Insert. Document the pattern in a comment on the helper so callers understand why the LockTable is inside a branch.

View file

@ -11,14 +11,14 @@ application-area: [all]
## Description ## Description
`ModifyAll` and `DeleteAll` normally compile to a single set-based SQL UPDATE or DELETE. That optimization is conditional: if any subscriber is bound to the table's modify or delete events — `OnBeforeModifyEvent`, `OnAfterModifyEvent`, `OnBeforeDeleteEvent`, `OnAfterDeleteEvent`, and their Rec counterparts — the server must invoke AL per affected row so the subscriber sees each record. The operation falls back to a row-by-row loop, one SQL statement per row, inside the same transaction. `ModifyAll` and `DeleteAll` normally compile to a single set-based SQL UPDATE or DELETE. That optimization is conditional: the server falls back to row-by-row execution when it must invoke AL per affected row. Common causes are global table delete triggers, table modify/delete event subscribers, and Media or MediaSet fields added to the table or a table extension.
The slowdown is invisible in the caller's source: the call site still reads as a bulk operation. It only shows up under load, and adding an apparently cheap subscriber (even an empty one, or one that guards on a condition and returns) is enough to trigger the fallback for every caller of ModifyAll/DeleteAll on that table across the system. Central tables — Item Ledger Entry, G/L Entry, Sales Line — are the worst places to attach such subscribers because every extension's bulk operation pays the cost. The slowdown is invisible in the caller's source: the call site still reads as a bulk operation. It only shows up under load, and adding an apparently cheap subscriber (even an empty one, or one that guards on a condition and returns) is enough to trigger the fallback for every caller of ModifyAll/DeleteAll on that table across the system. Central tables — Item Ledger Entry, G/L Entry, Sales Line — are the worst places to attach such subscribers because every extension's bulk operation pays the cost.
## Best Practice ## Best Practice
Before subscribing to a table's modify or delete events, consider whether the logic can live elsewhere — on the triggering action, on a specific OnValidate, or on a business-event publisher. If the subscriber is unavoidable, scope it as narrowly as possible and document that it forces row-by-row execution so future maintainers understand the cost. Watch PRs that add such subscribers to heavily-modified tables. Before subscribing to a table's modify or delete events, consider whether the logic can live elsewhere — on the triggering action, on a specific OnValidate, or on a business-event publisher. If the subscriber, global trigger, or Media/MediaSet field is unavoidable, document that the table may no longer support set-based ModifyAll/DeleteAll. When a table has not regressed, prefer a small number of ModifyAll/DeleteAll calls; they are still commonly 10-50x faster than a manual loop.
## Anti Pattern ## Anti Pattern
An empty or nearly-empty `OnAfterModifyEvent` subscriber on `Sales Line` added as a placeholder for future integration. Every `ModifyAll` on `Sales Line` — in the base app, in every extension, in every tenant — now runs one SQL UPDATE per row. An empty or nearly-empty `OnAfterModifyEvent` subscriber on `Sales Line` added as a placeholder for future integration. Every `ModifyAll` on `Sales Line` — in the base app, in every extension, in every tenant — can now run one SQL UPDATE per row. The same regression can come from a global delete trigger or from adding a Media field to the table.

View file

@ -0,0 +1,16 @@
codeunit 50934 "Perf Sample TempLookup Bad"
{
procedure MarkSeenCustomers(var SalesLine: Record "Sales Line")
var
TempCustomer: Record Customer temporary;
begin
if SalesLine.FindSet() then
repeat
if not TempCustomer.Get(SalesLine."Sell-to Customer No.") then begin
TempCustomer.Init();
TempCustomer."No." := SalesLine."Sell-to Customer No.";
TempCustomer.Insert();
end;
until SalesLine.Next() = 0;
end;
}

View file

@ -0,0 +1,13 @@
codeunit 50933 "Perf Sample Dictionary Good"
{
procedure MarkSeenCustomers(var SalesLine: Record "Sales Line")
var
SeenCustomerNos: Dictionary of [Code[20], Boolean];
begin
if SalesLine.FindSet() then
repeat
if not SeenCustomerNos.ContainsKey(SalesLine."Sell-to Customer No.") then
SeenCustomerNos.Add(SalesLine."Sell-to Customer No.", true);
until SalesLine.Next() = 0;
end;
}

View file

@ -0,0 +1,26 @@
---
bc-version: [all]
domain: performance
keywords: [dictionary, temporary-table, lookup, identity, o1]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Use Dictionary for temporary identity lookups
## Description
A temporary record is useful when code needs record semantics: filters, keys, FlowFields, or table-shaped buffers. When the only operation is "have I seen this key?" or "what value belongs to this key?", a `Dictionary` is the simpler and faster structure. Dictionary lookup is O(1) by key, while a temporary table still pays record and key-management overhead.
## Best Practice
Use `Dictionary` for in-memory lookup sets and maps whose keys fit in memory and whose access pattern is by identity. Keep temporary tables for data that needs table APIs, multiple keys, filter expressions, or later processing as records.
See sample: `use-dictionary-for-temporary-identity-lookups.good.al`.
## Anti Pattern
Creating a temporary table solely to call `Get` or `FindFirst` by a single key in a loop. The code looks familiar to AL developers, but it is heavier than the lookup problem requires.
See sample: `use-dictionary-for-temporary-identity-lookups.bad.al`.

View file

@ -11,11 +11,11 @@ application-area: [all]
## Description ## Description
FindSet has two modes: FindSet() and FindSet(false) are read-only and take no write lock; FindSet(true) calls LockTable before fetching. Write locks are expensive and hold for the remainder of the transaction, so passing `true` when you do not intend to modify the records increases contention under load. FindSet has two modes: FindSet() and FindSet(false) are read-only and take no update lock; FindSet(true) sets update-lock read isolation on the record before fetching. Update locks are expensive and hold for the lock scope, so passing `true` when you do not intend to modify the records increases contention under load.
## Best Practice ## Best Practice
Call FindSet with no arguments when the loop only reads field values. Pass `true` only when the same loop is expected to call Modify, Delete, or Rename on the record, and the correctness of the operation depends on the table being locked for the full iteration. Call FindSet with no arguments when the loop only reads field values. Pass `true` only when the same loop is expected to call Modify, Delete, or Rename on the record, and the correctness of the operation depends on the matching rows being locked for the iteration.
See sample: `use-findset-readonly-by-default.good.al`. See sample: `use-findset-readonly-by-default.good.al`.

View file

@ -15,7 +15,7 @@ SetLoadFields instructs the platform to hydrate only the listed fields on a reco
## Best Practice ## Best Practice
Call SetLoadFields before FindSet, FindFirst, or Get whenever the code path only reads a subset of fields. List every field that is read or written during the operation, including fields used in calculations and downstream function calls. Omitting a field that is later accessed triggers a second round-trip. Call SetLoadFields before FindSet, FindFirst, or Get when the table is wide enough to matter (roughly 10+ fields) and the code path reads a small subset (roughly under 60%) across a material number of rows. Short loops over narrow tables usually do not earn the extra coupling; see `skip-setloadfields-on-narrow-tables-and-short-loops` for that exception. List every field that is read or written during the operation, including fields used in calculations and downstream function calls. Omitting a field that is later accessed triggers a second round-trip.
Fields that appear **only** in SetRange or SetFilter calls do not need to be included — the database resolves the filter using the index without hydrating the value into AL memory. Including filter-only fields wastes bandwidth and is not required. Fields that appear **only** in SetRange or SetFilter calls do not need to be included — the database resolves the filter using the index without hydrating the value into AL memory. Including filter-only fields wastes bandwidth and is not required.

View file

@ -0,0 +1,14 @@
codeunit 50932 "Perf Sample TextConcat Bad"
{
procedure BuildItemList(var Item: Record Item): Text
var
Result: Text;
begin
if Item.FindSet() then
repeat
Result += StrSubstNo('%1,%2', Item."No.", Item.Description);
until Item.Next() = 0;
exit(Result);
end;
}

View file

@ -0,0 +1,14 @@
codeunit 50931 "Perf Sample TextBuilder Good"
{
procedure BuildItemList(var Item: Record Item): Text
var
Builder: TextBuilder;
begin
if Item.FindSet() then
repeat
Builder.AppendLine(StrSubstNo('%1,%2', Item."No.", Item.Description));
until Item.Next() = 0;
exit(Builder.ToText());
end;
}

View file

@ -0,0 +1,26 @@
---
bc-version: [all]
domain: performance
keywords: [textbuilder, string-concatenation, loop, text, allocation]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Use TextBuilder for loop-based string assembly
## Description
Repeated `Text := Text + ...` concatenation inside a loop reallocates and copies the growing string on every iteration. In AL, `TextBuilder` is the platform type for constructing larger text payloads incrementally. `StrSubstNo` remains appropriate for formatting one message; TextBuilder is for many appends, especially inside loops.
## Best Practice
Use `TextBuilder.Append` or `AppendLine` when assembling CSV rows, log payloads, JSON-ish diagnostic text, or other multi-line strings from repeated loop iterations. Convert to Text once, after the loop, with `ToText()`.
See sample: `use-textbuilder-for-loop-string-assembly.good.al`.
## Anti Pattern
Appending to the same Text variable on every iteration of a large loop. Each append copies the accumulated prefix again, so the cost grows with both row count and final string length.
See sample: `use-textbuilder-for-loop-string-assembly.bad.al`.

View file

@ -0,0 +1,14 @@
table 50936 "Migrated Employee"
{
fields
{
field(1; "Employee No."; Code[20])
{
DataClassification = ToBeClassified;
}
field(2; "Tax Identification No."; Text[30])
{
DataClassification = SystemMetadata;
}
}
}

View file

@ -0,0 +1,14 @@
table 50935 "Migrated Employee"
{
fields
{
field(1; "Employee No."; Code[20])
{
DataClassification = EndUserPseudonymousIdentifiers;
}
field(2; "Tax Identification No."; Text[30])
{
DataClassification = EndUserIdentifiableInformation;
}
}
}

View file

@ -0,0 +1,26 @@
---
bc-version: [all]
domain: privacy
keywords: [migration, dataclassification, hybrid, destination, pii]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Classify migrated data at the destination field
## Description
Hybrid migration codeunits such as HybridSL, HybridGP, and HybridBC legitimately process sensitive source data: tax IDs, employee identifiers, financial balances, and customer records. The privacy concern is not that the migration code touches the data. The concern is where the data lands: the destination table field must have a DataClassification value that matches the migrated content.
## Best Practice
When reviewing migration code, follow the assignment to the destination field and verify that the destination table declares an appropriate field-level or inherited DataClassification. Treat the migration procedure itself as expected business functionality; flag only missing or understated classification on the persistent destination.
See sample: `classify-data-at-migration-destination.good.al`.
## Anti Pattern
Flagging a migration procedure merely because it copies tax IDs or names from a source system. That creates false positives and misses the real issue: a destination field with no classification, `ToBeClassified`, or `SystemMetadata` for customer or employee data.
See sample: `classify-data-at-migration-destination.bad.al`.

View file

@ -15,12 +15,12 @@ The privacy concern with user-facing text is not what the authenticated user see
## Best Practice ## Best Practice
Free-text business content — customer names, email addresses, document numbers — is acceptable in Message, Confirm, and Notification. Treat Error text as if it will be read by telemetry consumers, because it will be. Use localized Labels with the fewest possible PII placeholders, or system identifiers (SystemId, primary key values) rather than personal data. Free-text business content — customer names, email addresses, document numbers — is acceptable in Message, Confirm, and Notification. Treat Error text as if it will be read by telemetry consumers, because it will be, but use direct Error substitution rather than pre-building the message. `Error(MyErr, EmailAddress)` is telemetry-safe; `Error(StrSubstNo(..., EmailAddress))` is not.
See sample: `error-is-logged-to-telemetry-message-is-not.good.al`. See sample: `error-is-logged-to-telemetry-message-is-not.good.al`.
## Anti Pattern ## Anti Pattern
Embedding customer emails, phone numbers, addresses, or names directly into Error strings — either as literals or via pre-built StrSubstNo output — because "the user will see this anyway." The user also sees Message and Confirm, but those are not logged. Error is. Embedding customer emails, phone numbers, addresses, or names as literals in an Error label or baking them into a Text value with StrSubstNo before calling Error. The user also sees Message and Confirm, but those are not logged. Error is logged, so dynamic customer data must stay as direct substitution arguments.
See sample: `error-is-logged-to-telemetry-message-is-not.bad.al`. See sample: `error-is-logged-to-telemetry-message-is-not.bad.al`.

View file

@ -0,0 +1,14 @@
codeunit 50936 "Privacy FeatureTelemetry Bad"
{
procedure LogExpenseReleased(EmployeeNo: Code[20]; UserName: Text)
var
FeatureTelemetry: Codeunit "Feature Telemetry";
CustomDimensions: Dictionary of [Text, Text];
begin
CustomDimensions.Add('EmployeeNo', EmployeeNo);
CustomDimensions.Add('UserName', UserName);
CustomDimensions.Add('LastError', GetLastErrorText());
FeatureTelemetry.LogUsage('0000EA1', 'Expense Agent', 'Document Released', CustomDimensions);
end;
}

View file

@ -0,0 +1,13 @@
codeunit 50935 "Privacy FeatureTelemetry Good"
{
procedure LogExpenseReleased()
var
FeatureTelemetry: Codeunit "Feature Telemetry";
CustomDimensions: Dictionary of [Text, Text];
begin
CustomDimensions.Add('DocumentType', 'Expense');
CustomDimensions.Add('LineCountBucket', '10-20');
FeatureTelemetry.LogUsage('0000EA1', 'Expense Agent', 'Document Released', CustomDimensions);
end;
}

View file

@ -0,0 +1,26 @@
---
bc-version: [all]
domain: privacy
keywords: [featuretelemetry, customdimensions, telemetry, pii, customercontent]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Keep customer data out of FeatureTelemetry custom dimensions
## Description
`Codeunit "Feature Telemetry"` writes telemetry through methods such as `LogUsage`, `LogUptake`, and `LogError`. The `CustomDimensions` dictionary passed to those methods is exported to the telemetry pipeline, so it has the same privacy boundary as `Session.LogMessage` dimensions. Customer names, email addresses, employee numbers, user IDs, security IDs, notes, and `GetLastErrorText()` do not become safe merely because they are structured dimensions.
## Best Practice
Log feature state, event names, counts, enum values, and non-personal technical identifiers. Omit customer and employee identifiers from `CustomDimensions`; if diagnostics need correlation, use a non-personal event ID or aggregate count instead.
See sample: `keep-customer-data-out-of-featuretelemetry-dimensions.good.al`.
## Anti Pattern
Adding employee numbers, user names, customer emails, free-text descriptions, or raw `GetLastErrorText()` to the `CustomDimensions` dictionary before calling `FeatureTelemetry.LogUsage`, `LogUptake`, or `LogError`.
See sample: `keep-customer-data-out-of-featuretelemetry-dimensions.bad.al`.

View file

@ -0,0 +1,22 @@
---
bc-version: [all]
domain: privacy
keywords: [tobeclassified, dataclassification, release, gdpr, placeholder]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Resolve ToBeClassified before release
## Description
`DataClassification = ToBeClassified` is a development marker, not a releasable privacy state. It tells reviewers and tooling that the field still needs classification work. Shipping it prevents data-subject, retention, and telemetry tooling from making a correct decision about the field.
## Best Practice
Replace every `ToBeClassified` value with the narrowest accurate classification before the PR ships to customers. If the field inherits a correct table-level DataClassification, remove the placeholder rather than leaving a field-level `ToBeClassified` override.
## Anti Pattern
Treating ToBeClassified as a safe default because the field is new or because the final classification is uncertain. Uncertainty should bias toward a stronger classification, not toward an unresolved placeholder.

View file

@ -11,7 +11,7 @@ application-area: [all]
## Description ## Description
`Session.LogMessage` accepts a DataClassification parameter that governs how the platform handles the logged content in the telemetry pipeline. Omitting it is a schema violation the platform cannot repair later. Embedding personal data — emails, names, phone numbers, addresses, filenames of user uploads — in the message string also defeats classification, because the pipeline sees opaque text and cannot selectively redact. `Session.LogMessage` accepts a DataClassification parameter that governs how the platform handles the logged content in the telemetry pipeline. Omitting it is a schema violation the platform cannot repair later. Embedding personal data — emails, names, phone numbers, addresses, filenames of user uploads — in the message string also defeats classification, because the pipeline sees opaque text and cannot selectively redact. The same privacy boundary applies to other telemetry surfaces such as `Codeunit "Feature Telemetry"` custom dimensions.
## Best Practice ## Best Practice

View file

@ -11,11 +11,11 @@ application-area: [all]
## Description ## Description
Error messages are captured by platform telemetry. When Error receives a format template and field references as substitution arguments (Error('... %1 ...', Customer."No.")), the platform inspects each field's DataClassification and omits sensitive values from telemetry automatically. When the caller pre-builds the message with StrSubstNo and then passes the resulting Text to Error, the platform sees a plain string with no field context and logs the whole thing verbatim — any PII already baked in is exported to telemetry. Error messages are captured by platform telemetry. When Error receives a format template and substitution arguments directly (`Error('... %1 ...', Value)`), the platform can classify and strip sensitive values before telemetry is written. This is true whether the arguments are record fields, local variables, function results, or other expressions. When the caller pre-builds the message with StrSubstNo and then passes the resulting Text to Error, the platform sees a plain string with no argument context and logs the whole thing verbatim — any PII already baked in is exported to telemetry.
## Best Practice ## Best Practice
Pass the template and the field references directly to Error. Declare the template as a Label with a Comment describing each placeholder. The platform's field-aware classification logic then takes care of what reaches telemetry. Pass the template and substitution arguments directly to Error. Declare the template as a Label with a Comment describing each placeholder. Do not flag direct Error substitution merely because an argument may contain a customer name, email address, or phone number; the platform intercepts those arguments before telemetry.
See sample: `strsubstno-prebuild-breaks-error-telemetry-classification.good.al`. See sample: `strsubstno-prebuild-breaks-error-telemetry-classification.good.al`.

View file

@ -17,13 +17,13 @@ Events in AL are extensibility contracts. Every subscriber — third-party, inte
## Best Practice ## Best Practice
Design event signatures to carry only the data a subscriber legitimately needs. Do not pass SecretText, credential material, or flags the publisher depends on for access control. If a subscriber needs to veto an action, model it as a separate OnBefore event whose Handled pattern is documented — not as a general-purpose var Boolean callers can flip. Design event signatures to carry only the data a subscriber legitimately needs. Do not pass SecretText, credential material, or flags the publisher depends on for access control. Guard variables such as `HasAccess`, `SkipValidation`, or `CanExport` must not be `var` parameters on an OnBefore event; notify subscribers after the internal check with value parameters they cannot mutate.
See sample: `do-not-expose-sensitive-data-in-event-publishers.good.al`. See sample: `do-not-expose-sensitive-data-in-event-publishers.good.al`.
## Anti Pattern ## Anti Pattern
An OnBeforeElevateAccess publisher that exposes `var CanAccess: Boolean` — any subscriber installed on the tenant can flip it to true and escalate. Or a publisher that passes a SecretText parameter it obtained internally, handing it to every subscriber. An OnBeforeElevateAccess publisher that exposes `var CanAccess: Boolean` or `var SkipValidation: Boolean` — any subscriber installed on the tenant can flip it to true and bypass the check. Or a publisher that passes a SecretText parameter it obtained internally, handing it to every subscriber.
See sample: `do-not-expose-sensitive-data-in-event-publishers.bad.al`. See sample: `do-not-expose-sensitive-data-in-event-publishers.bad.al`.

View file

@ -0,0 +1,12 @@
codeunit 50243 "Sec Sample RecordRef Bad"
{
procedure ArchiveRecord(RecId: RecordId)
var
RecRef: RecordRef;
begin
RecRef.Open(RecId.TableNo);
RecRef.Get(RecId);
RecRef.Delete();
RecRef.Close();
end;
}

View file

@ -0,0 +1,12 @@
codeunit 50242 "Sec Sample RecordRef Good"
{
internal procedure ArchiveRecord(RecId: RecordId)
var
RecRef: RecordRef;
begin
RecRef.Open(RecId.TableNo);
RecRef.Get(RecId);
RecRef.Delete();
RecRef.Close();
end;
}

View file

@ -0,0 +1,26 @@
---
bc-version: [all]
domain: security
keywords: [recordref, recordid, table-no, scope, inherentpermissions]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Keep caller-driven RecordRef.Open procedures non-public
## Description
A codeunit can hold permissions or `InherentPermissions` that its callers do not have. If it exposes a public procedure that accepts a table number or RecordId and calls `RecordRef.Open`, another extension can call that procedure to make the privileged codeunit open tables on its behalf. That turns a generic helper into a permission-bypass surface, especially for system tables.
## Best Practice
Procedures that call `RecordRef.Open` with a caller-provided table number must be `local`, `internal`, or `[Scope('OnPrem')]`. If the procedure truly must be public in SaaS, validate the table number against a narrow allowlist before opening the RecordRef.
See sample: `keep-recordref-open-callers-non-public.good.al`.
## Anti Pattern
A public helper such as `ArchiveRecord(RecId: RecordId)` that opens `RecId.TableNo` and then reads, modifies, or deletes through RecordRef. The helper compiles, but it lets untrusted callers choose which table the privileged code opens.
See sample: `keep-recordref-open-callers-non-public.bad.al`.

View file

@ -10,6 +10,7 @@ codeunit 50209 "Sec Sample IsolatedStorage Bad"
var var
ApiKey: Text; ApiKey: Text;
begin begin
// Public wrapper: another extension can call this to read the secret.
if IsolatedStorage.Get('ApiKey', DataScope::Module, ApiKey) then if IsolatedStorage.Get('ApiKey', DataScope::Module, ApiKey) then
exit(ApiKey); exit(ApiKey);
exit(''); exit('');

View file

@ -1,11 +1,11 @@
codeunit 50208 "Sec Sample IsolatedStorage Good" codeunit 50208 "Sec Sample IsolatedStorage Good"
{ {
procedure StoreApiKey(NewKey: SecretText) internal procedure StoreApiKey(NewKey: SecretText)
begin begin
IsolatedStorage.SetEncrypted('ApiKey', NewKey, DataScope::Module); IsolatedStorage.SetEncrypted('ApiKey', NewKey, DataScope::Module);
end; end;
procedure TryGetApiKey(var ApiKey: SecretText): Boolean local procedure TryGetApiKey(var ApiKey: SecretText): Boolean
begin begin
if IsolatedStorage.Contains('ApiKey', DataScope::Module) then if IsolatedStorage.Contains('ApiKey', DataScope::Module) then
exit(IsolatedStorage.Get('ApiKey', DataScope::Module, ApiKey)); exit(IsolatedStorage.Get('ApiKey', DataScope::Module, ApiKey));

View file

@ -17,13 +17,13 @@ IsolatedStorage is a per-extension, per-tenant key-value store. DataScope::Modul
## Best Practice ## Best Practice
Use IsolatedStorage.SetEncrypted to write secrets, IsolatedStorage.Contains to probe, and IsolatedStorage.Get into a SecretText destination to read. Choose DataScope::Company for per-company credentials (for example, a tenant-per-company service account) and DataScope::Module for extension-wide configuration. Use IsolatedStorage.SetEncrypted to write secrets, IsolatedStorage.Contains to probe, and IsolatedStorage.Get into a SecretText destination to read. Choose DataScope::Company for per-company credentials (for example, a tenant-per-company service account) and DataScope::Module for extension-wide configuration. Procedures that call IsolatedStorage.Get, Set, SetEncrypted, Contains, or Delete must be `local` or `internal`; a public wrapper lets other extensions call into your storage boundary.
See sample: `use-isolated-storage-for-module-and-company-secrets.good.al`. See sample: `use-isolated-storage-for-module-and-company-secrets.good.al`.
## Anti Pattern ## Anti Pattern
Storing secrets in a Setup table column as plain Text, or using IsolatedStorage.Set (unencrypted) for values that authenticate the extension to an external service. Both shapes leave the secret readable by anyone with read rights on the underlying storage. Storing secrets in a Setup table column as plain Text, using IsolatedStorage.Set (unencrypted) for values that authenticate the extension to an external service, or exposing a public Get/Set procedure around IsolatedStorage. The first two leave secrets readable; the public wrapper lets another extension exfiltrate or overwrite values through your codeunit.
See sample: `use-isolated-storage-for-module-and-company-secrets.bad.al`. See sample: `use-isolated-storage-for-module-and-company-secrets.bad.al`.

View file

@ -13,4 +13,9 @@ codeunit 50217 "Sec Sample NonDebuggable Bad"
JObject.Get('access_token', JToken); JObject.Get('access_token', JToken);
SessionToken := JToken.AsValue().AsText(); SessionToken := JToken.AsValue().AsText();
end; end;
procedure BuildAuthorizationHeader(ApiKey: SecretText): Text
begin
exit('Bearer ' + ApiKey.Unwrap());
end;
} }

View file

@ -12,4 +12,10 @@ codeunit 50216 "Sec Sample NonDebuggable Good"
JObject.Get('access_token', JToken); JObject.Get('access_token', JToken);
SessionToken := JToken.AsValue().AsText(); SessionToken := JToken.AsValue().AsText();
end; end;
[NonDebuggable]
procedure BuildAuthorizationHeader(ApiKey: SecretText): Text
begin
exit('Bearer ' + ApiKey.Unwrap());
end;
} }

View file

@ -13,17 +13,17 @@ application-area: [all]
## Description ## Description
SecretText transit (assignment between SecretText variables, parameters, and return values) is protected automatically. Extracting a secret from a Text source — for example, reading an access token out of a parsed JSON response — is a legitimate Text-to-SecretText conversion during which the plaintext exists. The [NonDebuggable] attribute prevents debuggers (regular and snapshot) from inspecting the procedure's locals, parameters, and return at that moment. SecretText transit (assignment between SecretText variables, parameters, and return values) is protected automatically. Extracting a secret from a Text source — for example, reading an access token out of a parsed JSON response — is a legitimate Text-to-SecretText conversion during which the plaintext exists. Calling `SecretText.Unwrap()` has the same exposure in the opposite direction: it materializes the secret as plain Text. The [NonDebuggable] attribute prevents debuggers (regular and snapshot) from inspecting the procedure's locals, parameters, and return at that moment.
## Best Practice ## Best Practice
Apply [NonDebuggable] to any procedure that reads a response body, parses it, and assigns the extracted secret to a SecretText out-parameter or return. Keep the procedure narrow: it SHOULD do the minimum work required to obtain the SecretText, and nothing else. Apply [NonDebuggable] to any procedure that reads a response body, parses it, and assigns the extracted secret to a SecretText out-parameter or return. Also apply it to every procedure that calls `Unwrap()` because the secret becomes plain Text inside that procedure. Keep the procedure narrow: it SHOULD do the minimum work required to obtain or unwrap the secret, and nothing else.
See sample: `use-nondebuggable-when-parsing-secrets.good.al`. See sample: `use-nondebuggable-when-parsing-secrets.good.al`.
## Anti Pattern ## Anti Pattern
Parsing a token response in a normal (debuggable) procedure. The plaintext token is visible in debug sessions and snapshots taken during the parse. Parsing a token response in a normal (debuggable) procedure, or calling `ApiKey.Unwrap()` there to build a legacy Text value. The plaintext token is visible in debug sessions and snapshots taken during the parse or unwrap.
See sample: `use-nondebuggable-when-parsing-secrets.bad.al`. See sample: `use-nondebuggable-when-parsing-secrets.bad.al`.

View file

@ -0,0 +1,10 @@
codeunit 50241 "Sec Sample Url Bad"
{
procedure Sync(ServiceUrl: Text)
var
Client: HttpClient;
Response: HttpResponseMessage;
begin
Client.Get(ServiceUrl, Response);
end;
}

View file

@ -0,0 +1,17 @@
codeunit 50240 "Sec Sample Url Good"
{
procedure Sync(ServiceUrl: Text)
var
Client: HttpClient;
Response: HttpResponseMessage;
Uri: Codeunit Uri;
ExpectedBaseUrl: Text;
begin
ExpectedBaseUrl := 'https://api.contoso.com';
if not Uri.AreURIsHaveSameHost(ServiceUrl, ExpectedBaseUrl) then
Error('Service URL must point to api.contoso.com.');
Client.Get(ServiceUrl, Response);
end;
}

View file

@ -0,0 +1,26 @@
---
bc-version: [all]
domain: security
keywords: [url, uri, httpclient, ssrf, validation, endpoint]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Validate user-configurable URLs before HTTP calls
## Description
URLs stored in setup tables or accepted from user input are user-configurable endpoints. Passing them directly to `HttpClient` lets a malicious or compromised setup value redirect the extension to internal services, metadata endpoints, or attacker-controlled hosts. Business Central's System Application `Uri` codeunit provides host and pattern validation helpers for this exact boundary.
## Best Practice
Before `HttpClient.Get`, `Post`, `Put`, or similar calls use a URL from a table field, validate it with `Uri.AreURIsHaveSameHost()` when the host must be fixed, or `Uri.IsValidURIPattern()` when a known URL pattern is allowed. Validate before writing the request body so sensitive payloads are never sent to an unexpected host.
See sample: `validate-user-configurable-urls-before-http-calls.good.al`.
## Anti Pattern
Reading `Setup."Service URL"` or `WebhookSetup."Callback URL"` and passing it directly to HttpClient. The code looks configurable, but it creates an SSRF path and can exfiltrate data to whichever host the setup row names.
See sample: `validate-user-configurable-urls-before-http-calls.bad.al`.

View file

@ -0,0 +1,14 @@
page 50731 "UI Caption Bad"
{
layout
{
area(Content)
{
field(CustomerName; Rec."Customer Name")
{
InstructionalText = 'Enter the customer name.';
ShowCaption = false;
}
}
}
}

View file

@ -0,0 +1,21 @@
page 50730 "UI Caption Good"
{
layout
{
area(Content)
{
group(Description)
{
Caption = 'Description';
field(DescriptionField; Rec.Description)
{
MultiLine = true;
ShowCaption = false;
}
}
field(CustomerName; Rec."Customer Name")
{
}
}
}
}

View file

@ -0,0 +1,26 @@
---
bc-version: [all]
domain: ui
keywords: [showcaption, editable, accessibility, screen-reader, label]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Keep captions on editable fields
## Description
`ShowCaption = false` on an editable page field removes the visible and accessible label that identifies the input. `InstructionalText` is not a replacement: it behaves like placeholder text, disappears after entry, and is not reliably announced as the field label. The default `ShowCaption = true` is the safe form-field pattern.
## Best Practice
Leave captions visible on editable fields. `ShowCaption = false` is acceptable for non-editable content fields, for fields inside a valid data-table grid pattern, and for the first visible field in a parent group with a visible non-empty caption; in that last pattern, the group caption becomes the accessible label.
See sample: `keep-captions-on-editable-fields.good.al`.
## Anti Pattern
Hiding the caption on an editable field because the page layout looks cleaner, or because `InstructionalText` appears to describe the input. Screen reader users lose the field label, and sighted users lose the persistent visual cue.
See sample: `keep-captions-on-editable-fields.bad.al`.

View file

@ -0,0 +1,22 @@
---
bc-version: [all]
domain: ui
keywords: [control-addin, javascript, accessibility, wcag, keyboard, aria]
technologies: [al, javascript]
countries: [w1]
application-area: [all]
---
# Manually review UI-rendering control add-in changes for accessibility
## Description
JavaScript control add-ins bypass much of the Business Central client's built-in accessibility support. Once the add-in renders its own HTML, JavaScript, or CSS, the extension owns WCAG 2.1 AA concerns such as accessible names, semantic HTML, keyboard navigation, color contrast, focus management, and 200% zoom/reflow. Automated review cannot exhaustively verify those behaviours.
## Best Practice
When a control add-in change touches DOM creation, templates, CSS, interaction handlers, ARIA attributes, dynamic visibility, or focus flow, include a manual accessibility review finding even if no specific defect is obvious. Do not require manual accessibility review for pure data processing or API changes that do not render UI.
## Anti Pattern
Treating a control add-in diff as clean because no AL page properties changed. A new `div`-based button without an accessible name, a keyboard trap, or a color-only status indicator lives in JavaScript and still affects Business Central users.

View file

@ -0,0 +1,19 @@
page 50735 "UI Style Bad"
{
layout
{
area(Content)
{
field(Score; Score)
{
Caption = 'Score';
Style = Favorable;
StyleExpr = IsGood;
}
}
}
var
Score: Integer;
IsGood: Boolean;
}

View file

@ -0,0 +1,19 @@
page 50734 "UI Style Good"
{
layout
{
area(Content)
{
field(ValidationStatus; ValidationStatus)
{
Caption = 'Validation status';
Style = Unfavorable;
StyleExpr = HasValidationErrors;
}
}
}
var
ValidationStatus: Text;
HasValidationErrors: Boolean;
}

View file

@ -0,0 +1,26 @@
---
bc-version: [all]
domain: ui
keywords: [style, styleexpr, favorable, unfavorable, ambiguous, accessibility]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Provide text meaning for semantic styles
## Description
Most Business Central page styles are cosmetic, but `Favorable`, `Unfavorable`, and `Ambiguous` communicate meaning through color. Color-only meaning is not accessible. A user who cannot perceive the style must still be able to determine whether the value is positive, negative, or uncertain from the caption, value, or nearby text.
## Best Practice
Use semantic styles only when the meaning is independently available: a caption such as "Error", a value such as "Failed", a signed number whose sign carries the meaning, or an adjacent status field. Cosmetic styles such as `Strong`, `Attention`, and `Subordinate` do not need this extra check. Cue tiles inside `cuegroup` are exempt because the client supplies accessible semantic labels.
See sample: `provide-text-meaning-for-semantic-styles.good.al`.
## Anti Pattern
Applying `Style = Favorable`, `Unfavorable`, or `Ambiguous` to a value whose text is neutral, such as "42" or "Open", without any caption or adjacent field explaining what the color means.
See sample: `provide-text-meaning-for-semantic-styles.bad.al`.

View file

@ -0,0 +1,24 @@
page 50733 "UI Grid Bad"
{
layout
{
area(Content)
{
grid(BalanceGrid)
{
GridLayout = Columns;
field(CustomerName; Rec."Customer Name")
{
ShowCaption = false;
}
group(BalanceColumn)
{
field(Balance; Rec.Balance)
{
ShowCaption = false;
}
}
}
}
}
}

View file

@ -0,0 +1,29 @@
page 50732 "UI Grid Good"
{
layout
{
area(Content)
{
grid(BalanceGrid)
{
GridLayout = Columns;
group(CustomerColumn)
{
ShowCaption = false;
field(CustomerName; Rec."Customer Name")
{
ShowCaption = false;
}
}
group(BalanceColumn)
{
ShowCaption = false;
field(Balance; Rec.Balance)
{
ShowCaption = false;
}
}
}
}
}
}

View file

@ -0,0 +1,26 @@
---
bc-version: [all]
domain: ui
keywords: [grid, fixed, showcaption, accessibility, table-semantics]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Use the grid data-table pattern consistently
## Description
Business Central `grid` and `fixed` layouts render either as data tables or layout tables based on a structural heuristic. A data table requires all direct children to be groups, every group child to be a field, and all fields to have `ShowCaption = false`. If the structure fails that heuristic, the client renders a layout table; hidden captions on editable fields then remove the only accessible labels.
## Best Practice
Use one pattern consistently. For a data-table grid, make every direct child a group and every field `ShowCaption = false`. For a layout grid, keep captions visible on editable or tabular fields and hide captions only on standalone non-editable content where the missing label is not a form-field problem.
See sample: `use-grid-data-table-pattern-consistently.good.al`.
## Anti Pattern
Mixing the patterns: one loose field, nested group, or visible field caption prevents data-table rendering, while other editable fields still hide captions. The result looks like a table visually but has layout-table semantics and missing labels for assistive technology.
See sample: `use-grid-data-table-pattern-consistently.bad.al`.

View file

@ -0,0 +1,22 @@
---
bc-version: [all]
domain: upgrade
keywords: [primary-key, field-type, existing-data, schema, breaking-change]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Assess existing data before primary-key or field-type changes
## Description
Primary-key and field-type changes are upgrade concerns because existing rows may no longer map safely to the new schema. The risk depends on whether the table already has tenant data and whether the old values can be converted without loss. New feature tables with no production rows do not have the same migration burden as ledger, document, or base application tables.
## Best Practice
For existing tables with data, require a concrete migration or compatibility assessment before changing keys or field types. For new tables, new feature tables, or Integer-to-BigInteger changes with evidence that existing values fit, avoid flagging a breaking-change finding without data-impact evidence.
## Anti Pattern
Treating every primary-key edit in a new feature table as a blocker while missing a key or type change on an established ledger-like table. Reviewers need to tie the finding to existing tenant data, not just to the syntactic shape of the schema edit.

View file

@ -15,7 +15,7 @@ The upgrade scope has to complete for the tenant to reach the new version. Any c
## Best Practice ## Best Practice
Defer external calls to runtime code that executes after the upgrade — install-triggered tasks, background job queue entries scheduled by the upgrade, or lazy initialization on first use. The upgrade step should compute a local result or mark work to be done, not perform the remote call itself. Defer external calls to runtime code that executes after the upgrade — install-triggered tasks, background job queue entries scheduled by the upgrade, or lazy initialization on first use. The upgrade step should compute a local result or mark work to be done, not perform the remote call itself. Do not apply this rule to ordinary runtime codeunits, pages, tables, install procedures, or background jobs unless they are directly invoked from an upgrade trigger.
## Anti Pattern ## Anti Pattern

View file

@ -0,0 +1,22 @@
---
bc-version: [all]
domain: upgrade
keywords: [hybrid, migration, upgrade-tag, false-positive, datamigration]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Exclude Hybrid migration codeunits from standard upgrade rules
## Description
Hybrid migration codeunits such as `HybridBC14`, `HybridSL`, `HybridGP`, and `HybridBaseDeployment` are one-time migration paths with established migration-specific patterns. They are not ordinary `Subtype = Upgrade` steps, and forcing standard upgrade-tag, trigger-shape, or missing-upgrade-code rules onto them creates false positives.
## Best Practice
When a change is clearly in a Hybrid migration codeunit or migration namespace, review it against migration-specific data handling and destination classification rules. Do not flag it merely because it lacks ordinary upgrade tags or because its control flow differs from standard upgrade codeunits.
## Anti Pattern
Reporting "missing upgrade tag" or "missing standard upgrade code" on a `HybridSL`, `HybridGP`, `HybridBC`, or `HybridBaseDeployment` codeunit solely because it does not look like a normal upgrade step. The name and migration context are the signal that different rules apply.

View file

@ -0,0 +1,13 @@
codeunit 50831 "Upgrade Sample Trigger Bad"
{
Subtype = Upgrade;
trigger OnValidateUpgradePerCompany()
begin
ValidateAllCustomers();
end;
local procedure ValidateAllCustomers()
begin
end;
}

View file

@ -0,0 +1,25 @@
codeunit 50830 "Upgrade Sample Trigger Good"
{
Subtype = Upgrade;
trigger OnValidateUpgradePerCompany()
var
UpgradeTag: Codeunit "Upgrade Tag";
begin
// Required for regulatory data validation before this release can run.
if UpgradeTag.HasUpgradeTag(ValidationTag()) then
exit;
ValidateAllCustomers();
UpgradeTag.SetUpgradeTag(ValidationTag());
end;
local procedure ValidateAllCustomers()
begin
end;
local procedure ValidationTag(): Code[250]
begin
exit('MS-000010-ValidateCustomers-20260501');
end;
}

View file

@ -0,0 +1,26 @@
---
bc-version: [all]
domain: upgrade
keywords: [onvalidateupgrade, trigger, upgrade-tag, performance, justification]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Guard performance-impacting upgrade triggers
## Description
Upgrade validation triggers such as `OnValidateUpgradePerCompany` can run during upgrade for every tenant and company. Expensive validation, full-table scans, or repair logic in those triggers becomes part of the upgrade's critical path. The trigger is acceptable only when the work is necessary and when re-execution is prevented.
## Best Practice
Add written justification for the trigger's work and guard it with an upgrade tag just like a data-migration step. Check `HasUpgradeTag` before the expensive work and call `SetUpgradeTag` only after the work succeeds, so retries do not re-run completed validation.
See sample: `guard-performance-impacting-upgrade-triggers.good.al`.
## Anti Pattern
Putting `ValidateAllCustomers()`, table scans, or external-style setup validation directly in `OnValidateUpgradePerCompany` without a skip tag. The work runs on every upgrade attempt, including retries after unrelated failures.
See sample: `guard-performance-impacting-upgrade-triggers.bad.al`.

View file

@ -15,7 +15,7 @@ The `InitValue` property sets a field's default for rows created after the field
## Best Practice ## Best Practice
When adding a field to an existing table with a meaningful default, write an upgrade step that populates existing rows with the same value, guarded by its own upgrade tag. Use `DataTransfer` with `AddConstantValue` for set-based initialization (see `use-datatransfer-for-large-dataset-initialization`). Exceptions: brand-new tables, new Boolean fields where `false` is the correct value for existing rows, and informational fields where empty is an acceptable state. When adding a field to an existing table with a meaningful default, write an upgrade step that populates existing rows with the same value, guarded by its own upgrade tag. Use `DataTransfer` with `AddConstantValue` for set-based initialization (see `use-datatransfer-for-large-dataset-initialization`). Exceptions: brand-new tables; new Boolean fields without InitValue where `false` is the intended existing-row value; new extensions, new feature tables, or setup tables with no meaningful existing data to migrate; and informational fields where empty is an acceptable state.
See sample: `initvalue-does-not-populate-existing-records.good.al`. See sample: `initvalue-does-not-populate-existing-records.good.al`.

View file

@ -15,12 +15,12 @@ An upgrade tag set via `UpgradeTag.SetUpgradeTag` only participates in the platf
## Best Practice ## Best Practice
For every upgrade-tag constant referenced in `HasUpgradeTag`/`SetUpgradeTag`, register it in the subscriber that matches its trigger scope: tags used from `OnUpgradePerCompany` go in `OnGetPerCompanyUpgradeTags`; tags used from `OnUpgradePerDatabase` go in `OnGetPerDatabaseUpgradeTags`. Keep the tag string in a single source (Label or function) and reference it at the guard, the setter, and the registration. For every upgrade-tag constant referenced in `HasUpgradeTag`/`SetUpgradeTag`, register it in the subscriber that matches its trigger scope: tags used from `OnUpgradePerCompany` go in `OnGetPerCompanyUpgradeTags`; tags used from `OnUpgradePerDatabase` go in `OnGetPerDatabaseUpgradeTags`. Treat this mapping as a review point, not just a naming convention. Keep the tag string in a single source (Label or function) and reference it at the guard, the setter, and the registration.
See sample: `register-upgrade-tags-with-subscribers.good.al`. See sample: `register-upgrade-tags-with-subscribers.good.al`.
## Anti Pattern ## Anti Pattern
Adding a new `UpgradeTag.SetUpgradeTag(MyTag())` without the matching `PerCompanyUpgradeTags.Add(MyTag())` in the registration subscriber. The code compiles and the step completes, but the tag is unregistered and the infrastructure is partially disabled. Adding a new `UpgradeTag.SetUpgradeTag(MyTag())` without the matching `PerCompanyUpgradeTags.Add(MyTag())` in the registration subscriber, or registering a tag used from `OnUpgradePerCompany` in `OnGetPerDatabaseUpgradeTags`. The code compiles and the step completes, but the tag is invisible or registered at the wrong scope.
See sample: `register-upgrade-tags-with-subscribers.bad.al`. See sample: `register-upgrade-tags-with-subscribers.bad.al`.

View file

@ -11,13 +11,13 @@ application-area: [all]
## Description ## Description
An upgrade that populates a new field on millions of existing rows with a FindSet+Modify loop pays a round-trip and a per-row trigger invocation for every row — turning a multi-hour upgrade into a multi-day one on ledger-entry-scale tables. `DataTransfer` pushes the update to SQL as a single set-based operation using source filters and constant values, which is the supported platform mechanism for this scenario. The tradeoff: DataTransfer bypasses validation triggers and event subscribers — if the step depends on trigger logic, that has to be reconstructed explicitly. An upgrade that populates a new field on existing rows with a FindSet+Modify loop pays a round-trip and a per-row trigger invocation for every row — turning a multi-hour upgrade into a multi-day one on ledger-entry-scale tables. `DataTransfer` pushes the update to SQL as a single set-based operation using source filters and constant values, which is the supported platform mechanism for this scenario. The tradeoff: DataTransfer bypasses validation triggers and event subscribers — if the step depends on trigger logic, that has to be reconstructed explicitly.
## Best Practice ## Best Practice
Use DataTransfer when initializing a new field on an existing table that **can contain more than 300,000 records**, or whenever a new field is added to an existing table and the initialization must run across all existing rows. Tables in the ledger-entry and document-line category reliably exceed this threshold; treat them as requiring DataTransfer by default. Use DataTransfer when a new field added to an existing table needs initialization across existing rows, and for any table that can contain more than 300,000 records. Tables in the ledger-entry and document-line category reliably exceed this threshold; treat them as requiring DataTransfer by default.
Set tables, add source filters, add constant values, call CopyFields, clear, and repeat for additional slices. When trigger or subscriber behaviour is required, do that work separately against a filtered result set so the bulk update remains set-based. Set tables, add source filters, add constant values, call CopyFields, clear, and repeat for additional slices. Use the pattern for new fields and tables added in the same change. If no new field or table is involved, document why validation triggers and event subscribers are safe to bypass, or keep the explicit loop that invokes the business logic.
See sample: `use-datatransfer-for-large-dataset-initialization.good.al`. See sample: `use-datatransfer-for-large-dataset-initialization.good.al`.

View file

@ -15,12 +15,12 @@ application-area: [all]
## Best Practice ## Best Practice
Guard each step with `if UpgradeTag.HasUpgradeTag(MyTag()) then exit;` at the top of the procedure. After the step completes, call `UpgradeTag.SetUpgradeTag(MyTag())`. Define the tag string in a `Tok`-suffixed Label or returning function so the same constant is referenced at both the guard and the registration (see `register-upgrade-tags-with-getpercompany-getperdatabase-subscribers`). Guard each standard upgrade step with `if UpgradeTag.HasUpgradeTag(MyTag()) then exit;` at the top of the procedure. After the step completes, call `UpgradeTag.SetUpgradeTag(MyTag())`. Define the tag string in a `Tok`-suffixed Label or returning function so the same constant is referenced at both the guard and the registration (see `register-upgrade-tags-with-getpercompany-getperdatabase-subscribers`). The supported DataVersion exception is first-install detection in `OnInstallAppPerCompany` with the `0.0.0.0` sentinel; one-time Hybrid migration codeunits follow separate migration patterns and should not be forced into ordinary upgrade-tag structure.
See sample: `use-upgrade-tags-not-version-checks.good.al`. See sample: `use-upgrade-tags-not-version-checks.good.al`.
## Anti Pattern ## Anti Pattern
`if MyApp.DataVersion().Major < 18 then UpgradeFeatureA();` — the step runs on every upgrade from a pre-18 version, may fail on partial data, and the next retry re-runs work that already succeeded. Nesting version-check branches (`< 14` → step A, `< 17` → step B) compounds the fragility. `if MyApp.DataVersion().Major < 18 then UpgradeFeatureA();` inside a standard upgrade step — the step runs on every upgrade from a pre-18 version, may fail on partial data, and the next retry re-runs work that already succeeded. Nesting version-check branches (`< 14` → step A, `< 17` → step B) compounds the fragility.
See sample: `use-upgrade-tags-not-version-checks.bad.al`. See sample: `use-upgrade-tags-not-version-checks.bad.al`.

View file

@ -39,7 +39,7 @@ Narrow the relevant files to the subset that applies to the changes under review
- The changed AL object names and types — especially tables, pages with SourceTable bindings, reports, queries, and codeunits performing record iteration. - The changed AL object names and types — especially tables, pages with SourceTable bindings, reports, queries, and codeunits performing record iteration.
- The changed procedures and triggers, weighted toward those that perform loops, Find/FindSet/FindFirst calls, CalcFields, CalcSums, FlowField access, or cross-table navigation. - The changed procedures and triggers, weighted toward those that perform loops, Find/FindSet/FindFirst calls, CalcFields, CalcSums, FlowField access, or cross-table navigation.
- Tokens extracted from the diff that relate to data access (SetRange, SetFilter, SetLoadFields, SetCurrentKey, FindSet, Repeat…Until, CalcFields, CalcSums). - Tokens extracted from the diff that relate to data access and hot-path costs (`SetRange`, `SetFilter`, `SetLoadFields`, `SetCurrentKey`, `FindSet`, `ReadIsolation`, `LockTable`, `ModifyAll`, `DeleteAll`, `TextBuilder`, `Dictionary`, `temporary`, `repeat`, `until`, `CalcFields`, `CalcSums`).
A file enters the candidate worklist when its `keywords` intersect the extracted tokens or its topic (derived from filename and Description) matches a changed object type. A file enters the candidate worklist when its `keywords` intersect the extracted tokens or its topic (derived from filename and Description) matches a changed object type.

View file

@ -35,11 +35,11 @@ Discard files that are not applicable. Retain conditionally applicable files (an
## Worklist ## Worklist
Narrow the relevant files to the subset that applies to the changes under review. For each relevant file, compute overlap against: Narrow the relevant files to the subset that applies to the changes under review. Exclude test codeunits, test libraries, test helper code, files under test/Test/Tests paths, and objects with `Subtype = Test`; test data is synthetic and does not ship to customers. For each relevant file, compute overlap against:
- The changed AL object names and types — especially tables and tableextensions (for `DataClassification` on fields), codeunits that call `Error` or `Session.LogMessage`, codeunits performing outgoing HTTP requests with customer data, and objects reading or writing `IsolatedStorage`. - The changed AL object names and types — especially tables and tableextensions (for `DataClassification` on fields), codeunits that call `Error`, `Session.LogMessage`, or `FeatureTelemetry`, codeunits performing outgoing HTTP requests with customer data, migration codeunits, and objects reading or writing `IsolatedStorage`.
- The changed procedures and triggers, weighted toward those that call `Error`, `Session.LogMessage`, `StrSubstNo`, `GetLastErrorText`, `HttpClient.Post`/`Get`, `IsolatedStorage.Set`/`SetEncrypted`/`Get`, or `PrivacyNotice.GetPrivacyNoticeApprovalState`. - The changed procedures and triggers, weighted toward those that call `Error`, `Session.LogMessage`, `StrSubstNo`, `GetLastErrorText`, `FeatureTelemetry.LogUsage`/`LogUptake`/`LogError`, `HttpClient.Post`/`Get`, `IsolatedStorage.Set`/`SetEncrypted`/`Get`, or `PrivacyNotice.GetPrivacyNoticeApprovalState`.
- Tokens extracted from the diff that relate to privacy (`DataClassification`, `CustomerContent`, `EndUserIdentifiableInformation`, `SystemMetadata`, `ToBeClassified`, `PrivacyNotice`, `GetLastErrorText`, `TelemetryScope`). - Tokens extracted from the diff that relate to privacy (`DataClassification`, `CustomerContent`, `EndUserIdentifiableInformation`, `EndUserPseudonymousIdentifiers`, `SystemMetadata`, `ToBeClassified`, `PrivacyNotice`, `GetLastErrorText`, `TelemetryScope`, `FeatureTelemetry`, `CustomDimensions`, `LogUsage`, `LogUptake`, `LogError`, `HybridSL`, `HybridGP`, `HybridBC`).
A file enters the candidate worklist when its `keywords` intersect the extracted tokens or its topic (derived from filename and Description) matches a changed object type. A file enters the candidate worklist when its `keywords` intersect the extracted tokens or its topic (derived from filename and Description) matches a changed object type.

View file

@ -37,9 +37,9 @@ Discard files that are not applicable. Retain conditionally applicable files (an
Narrow the relevant files to the subset that applies to the changes under review. For each relevant file, compute overlap against: Narrow the relevant files to the subset that applies to the changes under review. For each relevant file, compute overlap against:
- The changed AL object names and types — especially permission sets, codeunits handling authentication or authorization, objects touching `Isolated Storage`, `OAuth2` flows, web service endpoints, and API pages. - The changed AL object names and types — especially permission sets, codeunits handling authentication or authorization, objects touching `Isolated Storage`, `OAuth2` flows, web service endpoints, API pages, event publishers, and RecordRef helpers.
- The changed procedures and triggers, weighted toward those that call `HttpClient`, write to telemetry, read or write secrets, manipulate record-level security, or bypass the permission model (for example, `Record.WritePermission`, direct table access from a non-owning app). - The changed procedures and triggers, weighted toward those that call `HttpClient`, validate or compose URLs, write to telemetry, read or write secrets, unwrap SecretText, manipulate record-level security, expose var Boolean guard parameters, or bypass the permission model (for example, `RecordRef.Open`, `Record.WritePermission`, direct table access from a non-owning app).
- Tokens extracted from the diff that relate to security concerns (`IsolatedStorage`, `OAuth2`, `Secret`, `Password`, `Token`, `HttpClient`, `Permission`, `Session`, `UserSecurityId`, `Commit`). - Tokens extracted from the diff that relate to security concerns (`IsolatedStorage`, `SetEncrypted`, `OAuth2`, `SecretText`, `Unwrap`, `NonDebuggable`, `Password`, `Token`, `HttpClient`, `Uri`, `AreURIsHaveSameHost`, `IsValidURIPattern`, `RecordRef`, `RecordId`, `Open`, `IntegrationEvent`, `SkipValidation`, `HasAccess`, `Permission`, `UserSecurityId`, `Commit`).
A file enters the candidate worklist when its `keywords` intersect the extracted tokens or its topic (derived from filename and Description) matches a changed object type. A file enters the candidate worklist when its `keywords` intersect the extracted tokens or its topic (derived from filename and Description) matches a changed object type.

View file

@ -2,21 +2,21 @@
kind: action-skill kind: action-skill
id: al-ui-review id: al-ui-review
version: 1 version: 1
title: AL UI text review title: AL UI and accessibility review
description: Reviews AL page files against UI-text, caption, and tooltip guidance from BCQuality. description: Reviews AL page and control add-in UI files against UI text, caption, tooltip, and accessibility guidance from BCQuality.
inputs: [pr-diff, file-path] inputs: [pr-diff, file-path]
outputs: [findings-report] outputs: [findings-report]
bc-version: [all] bc-version: [all]
technologies: [al] technologies: [al, javascript]
countries: [w1] countries: [w1]
application-area: [all] application-area: [all]
--- ---
# AL UI text review # AL UI and accessibility review
Reviews AL page source against the `ui` knowledge domain in BCQuality and emits a findings report. This is a leaf action skill: it invokes no sub-skills. It is one of the skills composed by `al-code-review`. Reviews AL page source and control add-in UI files against the `ui` knowledge domain in BCQuality and emits a findings report. This is a leaf action skill: it invokes no sub-skills. It is one of the skills composed by `al-code-review`.
UI findings apply to page files — files that declare `PageType = ...`, including `*.Page.al` under the standard file-naming convention. The skill returns `not-applicable` when the diff contains no page changes. UI findings apply to page files — files that declare `PageType = ...`, including `*.Page.al` under the standard file-naming convention — and to JavaScript/CSS/HTML files that render Business Central control add-ins. The skill returns `not-applicable` when the diff contains no page or control add-in UI changes.
An orchestrator invokes this skill with either a `pr-diff` or a `file-path`. The skill produces a single JSON document conforming to the DO output contract. An orchestrator invokes this skill with either a `pr-diff` or a `file-path`. The skill produces a single JSON document conforming to the DO output contract.
@ -29,7 +29,7 @@ Collect all knowledge files under `*/knowledge/ui/**/*.md`, across every enabled
Apply the frontmatter matching rules defined in READ against the task context: Apply the frontmatter matching rules defined in READ against the task context:
- `bc-version` — the target BC version from the PR branch's `app.json` or the orchestrator-supplied version. If unavailable, the dimension is `unknown`. - `bc-version` — the target BC version from the PR branch's `app.json` or the orchestrator-supplied version. If unavailable, the dimension is `unknown`.
- `technologies``[al]`. - `technologies``[al]` or `[javascript]`.
- `countries` — the countries declared in the consuming app's `app.json`. If absent, `unknown`. - `countries` — the countries declared in the consuming app's `app.json`. If absent, `unknown`.
- `application-area` — pass the actual set declared by the changed objects; do not substitute `[all]`. - `application-area` — pass the actual set declared by the changed objects; do not substitute `[all]`.
@ -39,9 +39,9 @@ Discard files that are not applicable. Retain conditionally applicable files onl
Narrow the relevant files to the subset that applies to the changes under review. Narrow the relevant files to the subset that applies to the changes under review.
- **Page-file filter.** UI review applies only to files declaring `page`, `pageextension`, or `pagecustomization`. When the diff contains no such files, return `outcome: "not-applicable"` without evaluating knowledge files. - **UI-file filter.** UI review applies to files declaring `page`, `pageextension`, or `pagecustomization`, and to control add-in JavaScript/CSS/HTML that changes rendered UI. When the diff contains no such files, return `outcome: "not-applicable"` without evaluating knowledge files.
- For each relevant knowledge file, compute overlap against changed page declarations, weighted toward `Caption`, `ToolTip`, `AboutTitle`, `AboutText`, `OptionCaption`, action definitions, and field-level properties. - For each relevant knowledge file, compute overlap against changed page declarations and control add-in UI files, weighted toward `Caption`, `ToolTip`, `AboutTitle`, `AboutText`, `OptionCaption`, `ShowCaption`, `InstructionalText`, `GridLayout`, `Style`, `StyleExpr`, action definitions, field-level properties, DOM creation, ARIA attributes, and keyboard/focus handlers.
- Tokens extracted from the diff (`Caption`, `ToolTip`, `AboutTitle`, `AboutText`, `PageType`, `&`, `Specifies`, `Message(`, `Confirm(`, `Error(` in a page context, `Disabled`, `Invalid`, `Whitelist`, `Blacklist`, trailing punctuation patterns on captions). - Tokens extracted from the diff (`Caption`, `ToolTip`, `AboutTitle`, `AboutText`, `PageType`, `ShowCaption`, `InstructionalText`, `grid`, `fixed`, `GridLayout`, `Style`, `StyleExpr`, `Favorable`, `Unfavorable`, `Ambiguous`, `cuegroup`, `controladdin`, `usercontrol`, `aria-`, `tabindex`, `keydown`, `focus`, `innerHTML`, `createElement`, `&`, `Specifies`, `Message(`, `Confirm(`, `Error(` in a page context, `Disabled`, `Invalid`, `Whitelist`, `Blacklist`, trailing punctuation patterns on captions).
A file enters the candidate worklist when its `keywords` intersect the extracted tokens or its topic matches a changed page element. A file enters the candidate worklist when its `keywords` intersect the extracted tokens or its topic matches a changed page element.
@ -51,7 +51,7 @@ When the post-conflict worklist is empty because no applicable UI knowledge exis
## Action ## Action
For each worklist entry, evaluate the diff against the file's `## Best Practice` and `## Anti Pattern` sections. UI text findings are generally `minor` — they affect localization and polish rather than correctness. Reach for `major` only when a banned term appears in customer-facing text or a caption truncation is guaranteed at the stated character limit. For each worklist entry, evaluate the diff against the file's `## Best Practice` and `## Anti Pattern` sections. UI text findings are generally `minor` — they affect localization and polish rather than correctness. Accessibility findings for missing labels, broken grid semantics, semantic color without text meaning, or UI-rendering control add-in changes can be `major`; use `minor` for low-risk manual-review reminders and polish issues.
Set `confidence` to: Set `confidence` to:
@ -63,7 +63,7 @@ Outcome selection:
- `completed` — the skill evaluated every worklist item. - `completed` — the skill evaluated every worklist item.
- `no-knowledge` — no applicable UI knowledge survived filtering. - `no-knowledge` — no applicable UI knowledge survived filtering.
- `not-applicable` — the diff contains no page, pageextension, or pagecustomization files. - `not-applicable` — the diff contains no page, pageextension, pagecustomization, or control add-in UI files.
- `partial` — a budget was hit before the worklist was exhausted. - `partial` — a budget was hit before the worklist was exhausted.
- `failed` — an unrecoverable error occurred. - `failed` — an unrecoverable error occurred.

View file

@ -38,8 +38,8 @@ Discard files that are not applicable. Retain conditionally applicable files (an
Narrow the relevant files to the subset that applies to the changes under review. For each relevant file, compute overlap against: Narrow the relevant files to the subset that applies to the changes under review. For each relevant file, compute overlap against:
- The changed AL object names and types — especially codeunits with `Subtype = Upgrade` or `Subtype = Install`, tables and tableextensions adding or changing fields, enums and enumextensions, and objects under `Hybrid*`/`Migration`/`Upgrade` namespaces. - The changed AL object names and types — especially codeunits with `Subtype = Upgrade` or `Subtype = Install`, tables and tableextensions adding or changing fields, enums and enumextensions, and objects under `Hybrid*`/`Migration`/`Upgrade` namespaces.
- The changed triggers and procedures, weighted toward `OnUpgradePerCompany`, `OnUpgradePerDatabase`, `OnInstallAppPerCompany`, and the `OnGetPerCompanyUpgradeTags`/`OnGetPerDatabaseUpgradeTags` subscribers. - The changed triggers and procedures, weighted toward `OnUpgradePerCompany`, `OnUpgradePerDatabase`, `OnValidateUpgradePerCompany`, `OnValidateUpgradePerDatabase`, `OnInstallAppPerCompany`, and the `OnGetPerCompanyUpgradeTags`/`OnGetPerDatabaseUpgradeTags` subscribers.
- Tokens extracted from the diff that relate to upgrade concerns (`Subtype = Upgrade`, `Upgrade Tag`, `HasUpgradeTag`, `SetUpgradeTag`, `DataTransfer`, `InitValue`, `ObsoleteState`, `ObsoleteReason`, `ObsoleteTag`, `DataVersion`, `ExecutionContext`, `value(`, `enum`, `enumextension`). - Tokens extracted from the diff that relate to upgrade concerns (`Subtype = Upgrade`, `Upgrade Tag`, `HasUpgradeTag`, `SetUpgradeTag`, `OnValidateUpgrade`, `DataTransfer`, `CopyFields`, `InitValue`, `ObsoleteState`, `ObsoleteReason`, `ObsoleteTag`, `DataVersion`, `ExecutionContext`, `PrimaryKey`, `key(`, `field(`, `value(`, `enum`, `enumextension`, `HybridSL`, `HybridGP`, `HybridBC`, `HybridBaseDeployment`).
A file enters the candidate worklist when its `keywords` intersect the extracted tokens or its topic matches a changed object type. When the diff contains no upgrade-related changes by any of the above signals, return `outcome: "not-applicable"` without evaluating files. A file enters the candidate worklist when its `keywords` intersect the extracted tokens or its topic matches a changed object type. When the diff contains no upgrade-related changes by any of the above signals, return `outcome: "not-applicable"` without evaluating files.