diff --git a/community/knowledge/security/classify-every-field-with-dataclassification.md b/community/knowledge/security/classify-every-field-with-dataclassification.md index bca3219..1b854aa 100644 --- a/community/knowledge/security/classify-every-field-with-dataclassification.md +++ b/community/knowledge/security/classify-every-field-with-dataclassification.md @@ -11,16 +11,16 @@ application-area: [all] ## 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 -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`. ## 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`. diff --git a/microsoft/knowledge/performance/keep-event-subscribers-lightweight.good.al b/microsoft/knowledge/performance/keep-event-subscribers-lightweight.good.al new file mode 100644 index 0000000..df25047 --- /dev/null +++ b/microsoft/knowledge/performance/keep-event-subscribers-lightweight.good.al @@ -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; +} diff --git a/microsoft/knowledge/performance/keep-event-subscribers-lightweight.md b/microsoft/knowledge/performance/keep-event-subscribers-lightweight.md index a1fae2a..4aa59fb 100644 --- a/microsoft/knowledge/performance/keep-event-subscribers-lightweight.md +++ b/microsoft/knowledge/performance/keep-event-subscribers-lightweight.md @@ -17,7 +17,9 @@ Event subscribers run synchronously on the publisher's thread. If a subscriber d ## 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 diff --git a/microsoft/knowledge/performance/split-read-only-and-write-paths-to-avoid-locktable.good.al b/microsoft/knowledge/performance/split-read-only-and-write-paths-to-avoid-locktable.good.al index 2dba7e3..1eca2d9 100644 --- a/microsoft/knowledge/performance/split-read-only-and-write-paths-to-avoid-locktable.good.al +++ b/microsoft/knowledge/performance/split-read-only-and-write-paths-to-avoid-locktable.good.al @@ -2,7 +2,8 @@ codeunit 51204 "Perf Sample LockTable Good" { procedure GetOrCreate(var AgentStatus: Record "Integer"): Boolean begin - // Read path: no lock. + // Read path: consistent read on this record instance only. + AgentStatus.ReadIsolation := IsolationLevel::ReadCommitted; if AgentStatus.Get(1) then exit(true); diff --git a/microsoft/knowledge/performance/split-read-only-and-write-paths-to-avoid-locktable.md b/microsoft/knowledge/performance/split-read-only-and-write-paths-to-avoid-locktable.md index da729f0..f9dae60 100644 --- a/microsoft/knowledge/performance/split-read-only-and-write-paths-to-avoid-locktable.md +++ b/microsoft/knowledge/performance/split-read-only-and-write-paths-to-avoid-locktable.md @@ -11,11 +11,11 @@ application-area: [all] ## 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 -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. diff --git a/microsoft/knowledge/performance/table-event-subscribers-disable-bulk-modifyall-and-deleteall.md b/microsoft/knowledge/performance/table-event-subscribers-disable-bulk-modifyall-and-deleteall.md index 1674838..21ce82d 100644 --- a/microsoft/knowledge/performance/table-event-subscribers-disable-bulk-modifyall-and-deleteall.md +++ b/microsoft/knowledge/performance/table-event-subscribers-disable-bulk-modifyall-and-deleteall.md @@ -11,14 +11,14 @@ application-area: [all] ## 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. ## 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 -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. diff --git a/microsoft/knowledge/performance/use-dictionary-for-temporary-identity-lookups.bad.al b/microsoft/knowledge/performance/use-dictionary-for-temporary-identity-lookups.bad.al new file mode 100644 index 0000000..1155668 --- /dev/null +++ b/microsoft/knowledge/performance/use-dictionary-for-temporary-identity-lookups.bad.al @@ -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; +} diff --git a/microsoft/knowledge/performance/use-dictionary-for-temporary-identity-lookups.good.al b/microsoft/knowledge/performance/use-dictionary-for-temporary-identity-lookups.good.al new file mode 100644 index 0000000..ff6d499 --- /dev/null +++ b/microsoft/knowledge/performance/use-dictionary-for-temporary-identity-lookups.good.al @@ -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; +} diff --git a/microsoft/knowledge/performance/use-dictionary-for-temporary-identity-lookups.md b/microsoft/knowledge/performance/use-dictionary-for-temporary-identity-lookups.md new file mode 100644 index 0000000..0e2843c --- /dev/null +++ b/microsoft/knowledge/performance/use-dictionary-for-temporary-identity-lookups.md @@ -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`. diff --git a/microsoft/knowledge/performance/use-findset-readonly-by-default.md b/microsoft/knowledge/performance/use-findset-readonly-by-default.md index 602dba6..6843c91 100644 --- a/microsoft/knowledge/performance/use-findset-readonly-by-default.md +++ b/microsoft/knowledge/performance/use-findset-readonly-by-default.md @@ -11,11 +11,11 @@ application-area: [all] ## 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 -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`. diff --git a/microsoft/knowledge/performance/use-setloadfields-for-partial-records.md b/microsoft/knowledge/performance/use-setloadfields-for-partial-records.md index b7ab187..3acf4f2 100644 --- a/microsoft/knowledge/performance/use-setloadfields-for-partial-records.md +++ b/microsoft/knowledge/performance/use-setloadfields-for-partial-records.md @@ -15,7 +15,7 @@ SetLoadFields instructs the platform to hydrate only the listed fields on a reco ## 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. diff --git a/microsoft/knowledge/performance/use-textbuilder-for-loop-string-assembly.bad.al b/microsoft/knowledge/performance/use-textbuilder-for-loop-string-assembly.bad.al new file mode 100644 index 0000000..97bf89a --- /dev/null +++ b/microsoft/knowledge/performance/use-textbuilder-for-loop-string-assembly.bad.al @@ -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; +} diff --git a/microsoft/knowledge/performance/use-textbuilder-for-loop-string-assembly.good.al b/microsoft/knowledge/performance/use-textbuilder-for-loop-string-assembly.good.al new file mode 100644 index 0000000..417759c --- /dev/null +++ b/microsoft/knowledge/performance/use-textbuilder-for-loop-string-assembly.good.al @@ -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; +} diff --git a/microsoft/knowledge/performance/use-textbuilder-for-loop-string-assembly.md b/microsoft/knowledge/performance/use-textbuilder-for-loop-string-assembly.md new file mode 100644 index 0000000..32080b5 --- /dev/null +++ b/microsoft/knowledge/performance/use-textbuilder-for-loop-string-assembly.md @@ -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`. diff --git a/microsoft/knowledge/privacy/classify-data-at-migration-destination.bad.al b/microsoft/knowledge/privacy/classify-data-at-migration-destination.bad.al new file mode 100644 index 0000000..4300fd9 --- /dev/null +++ b/microsoft/knowledge/privacy/classify-data-at-migration-destination.bad.al @@ -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; + } + } +} diff --git a/microsoft/knowledge/privacy/classify-data-at-migration-destination.good.al b/microsoft/knowledge/privacy/classify-data-at-migration-destination.good.al new file mode 100644 index 0000000..44204a5 --- /dev/null +++ b/microsoft/knowledge/privacy/classify-data-at-migration-destination.good.al @@ -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; + } + } +} diff --git a/microsoft/knowledge/privacy/classify-data-at-migration-destination.md b/microsoft/knowledge/privacy/classify-data-at-migration-destination.md new file mode 100644 index 0000000..f582fcc --- /dev/null +++ b/microsoft/knowledge/privacy/classify-data-at-migration-destination.md @@ -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`. diff --git a/microsoft/knowledge/privacy/error-is-logged-to-telemetry-message-is-not.md b/microsoft/knowledge/privacy/error-is-logged-to-telemetry-message-is-not.md index cb3ec85..694c640 100644 --- a/microsoft/knowledge/privacy/error-is-logged-to-telemetry-message-is-not.md +++ b/microsoft/knowledge/privacy/error-is-logged-to-telemetry-message-is-not.md @@ -15,12 +15,12 @@ The privacy concern with user-facing text is not what the authenticated user see ## 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`. ## 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`. diff --git a/microsoft/knowledge/privacy/keep-customer-data-out-of-featuretelemetry-dimensions.bad.al b/microsoft/knowledge/privacy/keep-customer-data-out-of-featuretelemetry-dimensions.bad.al new file mode 100644 index 0000000..7411a0e --- /dev/null +++ b/microsoft/knowledge/privacy/keep-customer-data-out-of-featuretelemetry-dimensions.bad.al @@ -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; +} diff --git a/microsoft/knowledge/privacy/keep-customer-data-out-of-featuretelemetry-dimensions.good.al b/microsoft/knowledge/privacy/keep-customer-data-out-of-featuretelemetry-dimensions.good.al new file mode 100644 index 0000000..9300406 --- /dev/null +++ b/microsoft/knowledge/privacy/keep-customer-data-out-of-featuretelemetry-dimensions.good.al @@ -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; +} diff --git a/microsoft/knowledge/privacy/keep-customer-data-out-of-featuretelemetry-dimensions.md b/microsoft/knowledge/privacy/keep-customer-data-out-of-featuretelemetry-dimensions.md new file mode 100644 index 0000000..d9c1bc7 --- /dev/null +++ b/microsoft/knowledge/privacy/keep-customer-data-out-of-featuretelemetry-dimensions.md @@ -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`. diff --git a/microsoft/knowledge/privacy/resolve-tobeclassified-before-release.md b/microsoft/knowledge/privacy/resolve-tobeclassified-before-release.md new file mode 100644 index 0000000..4e8c99c --- /dev/null +++ b/microsoft/knowledge/privacy/resolve-tobeclassified-before-release.md @@ -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. diff --git a/microsoft/knowledge/privacy/specify-dataclassification-on-every-telemetry-call.md b/microsoft/knowledge/privacy/specify-dataclassification-on-every-telemetry-call.md index 042be42..cf8d257 100644 --- a/microsoft/knowledge/privacy/specify-dataclassification-on-every-telemetry-call.md +++ b/microsoft/knowledge/privacy/specify-dataclassification-on-every-telemetry-call.md @@ -11,7 +11,7 @@ application-area: [all] ## 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 diff --git a/microsoft/knowledge/privacy/strsubstno-prebuild-breaks-error-telemetry-classification.md b/microsoft/knowledge/privacy/strsubstno-prebuild-breaks-error-telemetry-classification.md index 8210ec8..f70c00e 100644 --- a/microsoft/knowledge/privacy/strsubstno-prebuild-breaks-error-telemetry-classification.md +++ b/microsoft/knowledge/privacy/strsubstno-prebuild-breaks-error-telemetry-classification.md @@ -11,11 +11,11 @@ application-area: [all] ## 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 -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`. diff --git a/microsoft/knowledge/security/do-not-expose-sensitive-data-in-event-publishers.md b/microsoft/knowledge/security/do-not-expose-sensitive-data-in-event-publishers.md index e743bf1..e5e3e08 100644 --- a/microsoft/knowledge/security/do-not-expose-sensitive-data-in-event-publishers.md +++ b/microsoft/knowledge/security/do-not-expose-sensitive-data-in-event-publishers.md @@ -17,13 +17,13 @@ Events in AL are extensibility contracts. Every subscriber — third-party, inte ## 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`. ## 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`. diff --git a/microsoft/knowledge/security/keep-recordref-open-callers-non-public.bad.al b/microsoft/knowledge/security/keep-recordref-open-callers-non-public.bad.al new file mode 100644 index 0000000..3081231 --- /dev/null +++ b/microsoft/knowledge/security/keep-recordref-open-callers-non-public.bad.al @@ -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; +} diff --git a/microsoft/knowledge/security/keep-recordref-open-callers-non-public.good.al b/microsoft/knowledge/security/keep-recordref-open-callers-non-public.good.al new file mode 100644 index 0000000..ec005d1 --- /dev/null +++ b/microsoft/knowledge/security/keep-recordref-open-callers-non-public.good.al @@ -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; +} diff --git a/microsoft/knowledge/security/keep-recordref-open-callers-non-public.md b/microsoft/knowledge/security/keep-recordref-open-callers-non-public.md new file mode 100644 index 0000000..609374f --- /dev/null +++ b/microsoft/knowledge/security/keep-recordref-open-callers-non-public.md @@ -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`. diff --git a/microsoft/knowledge/security/use-isolated-storage-for-module-and-company-secrets.bad.al b/microsoft/knowledge/security/use-isolated-storage-for-module-and-company-secrets.bad.al index 7cf0cfa..e0572f6 100644 --- a/microsoft/knowledge/security/use-isolated-storage-for-module-and-company-secrets.bad.al +++ b/microsoft/knowledge/security/use-isolated-storage-for-module-and-company-secrets.bad.al @@ -10,6 +10,7 @@ codeunit 50209 "Sec Sample IsolatedStorage Bad" var ApiKey: Text; begin + // Public wrapper: another extension can call this to read the secret. if IsolatedStorage.Get('ApiKey', DataScope::Module, ApiKey) then exit(ApiKey); exit(''); diff --git a/microsoft/knowledge/security/use-isolated-storage-for-module-and-company-secrets.good.al b/microsoft/knowledge/security/use-isolated-storage-for-module-and-company-secrets.good.al index a22b568..5dafba8 100644 --- a/microsoft/knowledge/security/use-isolated-storage-for-module-and-company-secrets.good.al +++ b/microsoft/knowledge/security/use-isolated-storage-for-module-and-company-secrets.good.al @@ -1,11 +1,11 @@ codeunit 50208 "Sec Sample IsolatedStorage Good" { - procedure StoreApiKey(NewKey: SecretText) + internal procedure StoreApiKey(NewKey: SecretText) begin IsolatedStorage.SetEncrypted('ApiKey', NewKey, DataScope::Module); end; - procedure TryGetApiKey(var ApiKey: SecretText): Boolean + local procedure TryGetApiKey(var ApiKey: SecretText): Boolean begin if IsolatedStorage.Contains('ApiKey', DataScope::Module) then exit(IsolatedStorage.Get('ApiKey', DataScope::Module, ApiKey)); diff --git a/microsoft/knowledge/security/use-isolated-storage-for-module-and-company-secrets.md b/microsoft/knowledge/security/use-isolated-storage-for-module-and-company-secrets.md index 3ef53a1..7e9d15b 100644 --- a/microsoft/knowledge/security/use-isolated-storage-for-module-and-company-secrets.md +++ b/microsoft/knowledge/security/use-isolated-storage-for-module-and-company-secrets.md @@ -17,13 +17,13 @@ IsolatedStorage is a per-extension, per-tenant key-value store. DataScope::Modul ## 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`. ## 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`. diff --git a/microsoft/knowledge/security/use-nondebuggable-when-parsing-secrets.bad.al b/microsoft/knowledge/security/use-nondebuggable-when-parsing-secrets.bad.al index 2fc4321..4058959 100644 --- a/microsoft/knowledge/security/use-nondebuggable-when-parsing-secrets.bad.al +++ b/microsoft/knowledge/security/use-nondebuggable-when-parsing-secrets.bad.al @@ -13,4 +13,9 @@ codeunit 50217 "Sec Sample NonDebuggable Bad" JObject.Get('access_token', JToken); SessionToken := JToken.AsValue().AsText(); end; + + procedure BuildAuthorizationHeader(ApiKey: SecretText): Text + begin + exit('Bearer ' + ApiKey.Unwrap()); + end; } diff --git a/microsoft/knowledge/security/use-nondebuggable-when-parsing-secrets.good.al b/microsoft/knowledge/security/use-nondebuggable-when-parsing-secrets.good.al index d055890..23b00a9 100644 --- a/microsoft/knowledge/security/use-nondebuggable-when-parsing-secrets.good.al +++ b/microsoft/knowledge/security/use-nondebuggable-when-parsing-secrets.good.al @@ -12,4 +12,10 @@ codeunit 50216 "Sec Sample NonDebuggable Good" JObject.Get('access_token', JToken); SessionToken := JToken.AsValue().AsText(); end; + + [NonDebuggable] + procedure BuildAuthorizationHeader(ApiKey: SecretText): Text + begin + exit('Bearer ' + ApiKey.Unwrap()); + end; } diff --git a/microsoft/knowledge/security/use-nondebuggable-when-parsing-secrets.md b/microsoft/knowledge/security/use-nondebuggable-when-parsing-secrets.md index 819a310..19c776c 100644 --- a/microsoft/knowledge/security/use-nondebuggable-when-parsing-secrets.md +++ b/microsoft/knowledge/security/use-nondebuggable-when-parsing-secrets.md @@ -13,17 +13,17 @@ application-area: [all] ## 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 -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`. ## 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`. diff --git a/microsoft/knowledge/security/validate-user-configurable-urls-before-http-calls.bad.al b/microsoft/knowledge/security/validate-user-configurable-urls-before-http-calls.bad.al new file mode 100644 index 0000000..391f298 --- /dev/null +++ b/microsoft/knowledge/security/validate-user-configurable-urls-before-http-calls.bad.al @@ -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; +} diff --git a/microsoft/knowledge/security/validate-user-configurable-urls-before-http-calls.good.al b/microsoft/knowledge/security/validate-user-configurable-urls-before-http-calls.good.al new file mode 100644 index 0000000..5fb72fb --- /dev/null +++ b/microsoft/knowledge/security/validate-user-configurable-urls-before-http-calls.good.al @@ -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; +} diff --git a/microsoft/knowledge/security/validate-user-configurable-urls-before-http-calls.md b/microsoft/knowledge/security/validate-user-configurable-urls-before-http-calls.md new file mode 100644 index 0000000..7a26165 --- /dev/null +++ b/microsoft/knowledge/security/validate-user-configurable-urls-before-http-calls.md @@ -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`. diff --git a/microsoft/knowledge/ui/keep-captions-on-editable-fields.bad.al b/microsoft/knowledge/ui/keep-captions-on-editable-fields.bad.al new file mode 100644 index 0000000..0d206a1 --- /dev/null +++ b/microsoft/knowledge/ui/keep-captions-on-editable-fields.bad.al @@ -0,0 +1,14 @@ +page 50731 "UI Caption Bad" +{ + layout + { + area(Content) + { + field(CustomerName; Rec."Customer Name") + { + InstructionalText = 'Enter the customer name.'; + ShowCaption = false; + } + } + } +} diff --git a/microsoft/knowledge/ui/keep-captions-on-editable-fields.good.al b/microsoft/knowledge/ui/keep-captions-on-editable-fields.good.al new file mode 100644 index 0000000..adb3ccd --- /dev/null +++ b/microsoft/knowledge/ui/keep-captions-on-editable-fields.good.al @@ -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") + { + } + } + } +} diff --git a/microsoft/knowledge/ui/keep-captions-on-editable-fields.md b/microsoft/knowledge/ui/keep-captions-on-editable-fields.md new file mode 100644 index 0000000..aeea903 --- /dev/null +++ b/microsoft/knowledge/ui/keep-captions-on-editable-fields.md @@ -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`. diff --git a/microsoft/knowledge/ui/manually-review-control-addin-ui-accessibility.md b/microsoft/knowledge/ui/manually-review-control-addin-ui-accessibility.md new file mode 100644 index 0000000..03123aa --- /dev/null +++ b/microsoft/knowledge/ui/manually-review-control-addin-ui-accessibility.md @@ -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. diff --git a/microsoft/knowledge/ui/provide-text-meaning-for-semantic-styles.bad.al b/microsoft/knowledge/ui/provide-text-meaning-for-semantic-styles.bad.al new file mode 100644 index 0000000..5af36ca --- /dev/null +++ b/microsoft/knowledge/ui/provide-text-meaning-for-semantic-styles.bad.al @@ -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; +} diff --git a/microsoft/knowledge/ui/provide-text-meaning-for-semantic-styles.good.al b/microsoft/knowledge/ui/provide-text-meaning-for-semantic-styles.good.al new file mode 100644 index 0000000..496d892 --- /dev/null +++ b/microsoft/knowledge/ui/provide-text-meaning-for-semantic-styles.good.al @@ -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; +} diff --git a/microsoft/knowledge/ui/provide-text-meaning-for-semantic-styles.md b/microsoft/knowledge/ui/provide-text-meaning-for-semantic-styles.md new file mode 100644 index 0000000..f266288 --- /dev/null +++ b/microsoft/knowledge/ui/provide-text-meaning-for-semantic-styles.md @@ -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`. diff --git a/microsoft/knowledge/ui/use-grid-data-table-pattern-consistently.bad.al b/microsoft/knowledge/ui/use-grid-data-table-pattern-consistently.bad.al new file mode 100644 index 0000000..2b34f6c --- /dev/null +++ b/microsoft/knowledge/ui/use-grid-data-table-pattern-consistently.bad.al @@ -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; + } + } + } + } + } +} diff --git a/microsoft/knowledge/ui/use-grid-data-table-pattern-consistently.good.al b/microsoft/knowledge/ui/use-grid-data-table-pattern-consistently.good.al new file mode 100644 index 0000000..c290629 --- /dev/null +++ b/microsoft/knowledge/ui/use-grid-data-table-pattern-consistently.good.al @@ -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; + } + } + } + } + } +} diff --git a/microsoft/knowledge/ui/use-grid-data-table-pattern-consistently.md b/microsoft/knowledge/ui/use-grid-data-table-pattern-consistently.md new file mode 100644 index 0000000..b30dcd1 --- /dev/null +++ b/microsoft/knowledge/ui/use-grid-data-table-pattern-consistently.md @@ -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`. diff --git a/microsoft/knowledge/upgrade/assess-existing-data-before-key-or-type-changes.md b/microsoft/knowledge/upgrade/assess-existing-data-before-key-or-type-changes.md new file mode 100644 index 0000000..63ffed1 --- /dev/null +++ b/microsoft/knowledge/upgrade/assess-existing-data-before-key-or-type-changes.md @@ -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. diff --git a/microsoft/knowledge/upgrade/do-not-make-external-calls-in-upgrade-codeunits.md b/microsoft/knowledge/upgrade/do-not-make-external-calls-in-upgrade-codeunits.md index 57bdde3..e7bf1e6 100644 --- a/microsoft/knowledge/upgrade/do-not-make-external-calls-in-upgrade-codeunits.md +++ b/microsoft/knowledge/upgrade/do-not-make-external-calls-in-upgrade-codeunits.md @@ -15,7 +15,7 @@ The upgrade scope has to complete for the tenant to reach the new version. Any c ## 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 diff --git a/microsoft/knowledge/upgrade/exclude-hybrid-migration-codeunits-from-standard-upgrade-rules.md b/microsoft/knowledge/upgrade/exclude-hybrid-migration-codeunits-from-standard-upgrade-rules.md new file mode 100644 index 0000000..66bad0a --- /dev/null +++ b/microsoft/knowledge/upgrade/exclude-hybrid-migration-codeunits-from-standard-upgrade-rules.md @@ -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. diff --git a/microsoft/knowledge/upgrade/guard-performance-impacting-upgrade-triggers.bad.al b/microsoft/knowledge/upgrade/guard-performance-impacting-upgrade-triggers.bad.al new file mode 100644 index 0000000..08f88b1 --- /dev/null +++ b/microsoft/knowledge/upgrade/guard-performance-impacting-upgrade-triggers.bad.al @@ -0,0 +1,13 @@ +codeunit 50831 "Upgrade Sample Trigger Bad" +{ + Subtype = Upgrade; + + trigger OnValidateUpgradePerCompany() + begin + ValidateAllCustomers(); + end; + + local procedure ValidateAllCustomers() + begin + end; +} diff --git a/microsoft/knowledge/upgrade/guard-performance-impacting-upgrade-triggers.good.al b/microsoft/knowledge/upgrade/guard-performance-impacting-upgrade-triggers.good.al new file mode 100644 index 0000000..7e29e43 --- /dev/null +++ b/microsoft/knowledge/upgrade/guard-performance-impacting-upgrade-triggers.good.al @@ -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; +} diff --git a/microsoft/knowledge/upgrade/guard-performance-impacting-upgrade-triggers.md b/microsoft/knowledge/upgrade/guard-performance-impacting-upgrade-triggers.md new file mode 100644 index 0000000..0bfd375 --- /dev/null +++ b/microsoft/knowledge/upgrade/guard-performance-impacting-upgrade-triggers.md @@ -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`. diff --git a/microsoft/knowledge/upgrade/initvalue-does-not-populate-existing-records.md b/microsoft/knowledge/upgrade/initvalue-does-not-populate-existing-records.md index 6f0b612..66669ac 100644 --- a/microsoft/knowledge/upgrade/initvalue-does-not-populate-existing-records.md +++ b/microsoft/knowledge/upgrade/initvalue-does-not-populate-existing-records.md @@ -15,7 +15,7 @@ The `InitValue` property sets a field's default for rows created after the field ## 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`. diff --git a/microsoft/knowledge/upgrade/register-upgrade-tags-with-subscribers.md b/microsoft/knowledge/upgrade/register-upgrade-tags-with-subscribers.md index fd260dd..9f34007 100644 --- a/microsoft/knowledge/upgrade/register-upgrade-tags-with-subscribers.md +++ b/microsoft/knowledge/upgrade/register-upgrade-tags-with-subscribers.md @@ -15,12 +15,12 @@ An upgrade tag set via `UpgradeTag.SetUpgradeTag` only participates in the platf ## 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`. ## 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`. diff --git a/microsoft/knowledge/upgrade/use-datatransfer-for-large-dataset-initialization.md b/microsoft/knowledge/upgrade/use-datatransfer-for-large-dataset-initialization.md index ba14648..96d7526 100644 --- a/microsoft/knowledge/upgrade/use-datatransfer-for-large-dataset-initialization.md +++ b/microsoft/knowledge/upgrade/use-datatransfer-for-large-dataset-initialization.md @@ -11,13 +11,13 @@ application-area: [all] ## 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 -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`. diff --git a/microsoft/knowledge/upgrade/use-upgrade-tags-not-version-checks.md b/microsoft/knowledge/upgrade/use-upgrade-tags-not-version-checks.md index 68f81c0..86f82bb 100644 --- a/microsoft/knowledge/upgrade/use-upgrade-tags-not-version-checks.md +++ b/microsoft/knowledge/upgrade/use-upgrade-tags-not-version-checks.md @@ -15,12 +15,12 @@ application-area: [all] ## 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`. ## 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`. diff --git a/microsoft/skills/review/al-performance-review.md b/microsoft/skills/review/al-performance-review.md index 4c143e4..92933bf 100644 --- a/microsoft/skills/review/al-performance-review.md +++ b/microsoft/skills/review/al-performance-review.md @@ -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 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. diff --git a/microsoft/skills/review/al-privacy-review.md b/microsoft/skills/review/al-privacy-review.md index 15a385c..f78f9af 100644 --- a/microsoft/skills/review/al-privacy-review.md +++ b/microsoft/skills/review/al-privacy-review.md @@ -35,11 +35,11 @@ Discard files that are not applicable. Retain conditionally applicable files (an ## 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 procedures and triggers, weighted toward those that call `Error`, `Session.LogMessage`, `StrSubstNo`, `GetLastErrorText`, `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`). +- 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`, `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`, `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. diff --git a/microsoft/skills/review/al-security-review.md b/microsoft/skills/review/al-security-review.md index 126cd9c..c9b326e 100644 --- a/microsoft/skills/review/al-security-review.md +++ b/microsoft/skills/review/al-security-review.md @@ -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: -- 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 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). -- Tokens extracted from the diff that relate to security concerns (`IsolatedStorage`, `OAuth2`, `Secret`, `Password`, `Token`, `HttpClient`, `Permission`, `Session`, `UserSecurityId`, `Commit`). +- 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`, 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`, `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. diff --git a/microsoft/skills/review/al-ui-review.md b/microsoft/skills/review/al-ui-review.md index 70e8c52..463ee45 100644 --- a/microsoft/skills/review/al-ui-review.md +++ b/microsoft/skills/review/al-ui-review.md @@ -2,21 +2,21 @@ kind: action-skill id: al-ui-review version: 1 -title: AL UI text review -description: Reviews AL page files against UI-text, caption, and tooltip guidance from BCQuality. +title: AL UI and accessibility review +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] outputs: [findings-report] bc-version: [all] -technologies: [al] +technologies: [al, javascript] countries: [w1] 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. @@ -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: - `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`. - `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. -- **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. -- For each relevant knowledge file, compute overlap against changed page declarations, weighted toward `Caption`, `ToolTip`, `AboutTitle`, `AboutText`, `OptionCaption`, action definitions, and field-level properties. -- 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). +- **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 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`, `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. @@ -51,7 +51,7 @@ When the post-conflict worklist is empty because no applicable UI knowledge exis ## 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: @@ -63,7 +63,7 @@ Outcome selection: - `completed` — the skill evaluated every worklist item. - `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. - `failed` — an unrecoverable error occurred. diff --git a/microsoft/skills/review/al-upgrade-review.md b/microsoft/skills/review/al-upgrade-review.md index b66ed36..313694c 100644 --- a/microsoft/skills/review/al-upgrade-review.md +++ b/microsoft/skills/review/al-upgrade-review.md @@ -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: - 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. -- 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`). +- 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`, `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.