diff --git a/.github/scripts/validate_frontmatter.py b/.github/scripts/validate_frontmatter.py index df579e2..d969cef 100644 --- a/.github/scripts/validate_frontmatter.py +++ b/.github/scripts/validate_frontmatter.py @@ -145,10 +145,19 @@ def is_non_empty_list_of_str(value: Any) -> bool: return isinstance(value, list) and len(value) > 0 and all(isinstance(v, str) and v for v in value) -def expand_bc_version(value: Any) -> tuple[list[int] | None, str | None]: - """Return (expanded-list, error-message). One of the two is None.""" +def expand_bc_version(value: Any) -> tuple[list[int] | str | None, str | None]: + """Return (expanded, error-message). One of the two is None. + + For the universal sentinel ["all"], `expanded` is the string "all". + Otherwise it is the expanded list of version integers. + """ if not isinstance(value, list) or not value: return None, "must be a non-empty list" + # Case 0: universal sentinel + if len(value) == 1 and value[0] == "all": + return "all", None + if "all" in value: + return None, "'all' is mutually exclusive with explicit versions" # Case 1: all integers if all(isinstance(v, int) and not isinstance(v, bool) for v in value): if any(v <= 0 for v in value): @@ -162,7 +171,7 @@ def expand_bc_version(value: Any) -> tuple[list[int] | None, str | None]: if start > end: return None, f"range '{value[0]}' is not ascending" return list(range(start, end + 1)), None - return None, "must be a list of integers or a single-element range shorthand like [26..28]" + return None, "must be [all], a list of integers, or a single-element range shorthand like [26..28]" def headings_in_order(body: str) -> list[tuple[str, int]]: diff --git a/README.md b/README.md index ed222d7..e7a180e 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,29 @@ +# ⚠️ Warning +This project is under active development. +Large and potentially breaking changes are expected. + +**Public preview will soon be announced.** + # BCQuality Quality skills and knowledge for Business Central development. BCQuality is a curated knowledge base and skills library for Business Central. It provides structured, machine-readable guidance that development agents and tools can consume — establishing a consistent quality bar across tooling and teams. +## What belongs here + +BCQuality is a remedial knowledge base. A file exists because a capable LLM **would get something wrong, or miss something, without it** — not because the topic is important. The admission test for a knowledge file is one question: + +> If this file did not exist, would a modern LLM reviewing or generating BC code make a mistake this file would have prevented? + +If the answer is no — the advice is generic software-engineering guidance, or the LLM already knows the BC mechanic in question — the file does not belong here, regardless of how sound the content is. A file earns its place by encoding something BC-specific that LLMs demonstrably get wrong: a CodeCop rule number, a platform API whose semantics the training data gets backwards, a non-obvious ordering rule, a BC property whose default is a footgun. + +Good fit: "`SetLoadFields` must be called before filters, not after" (non-obvious ordering rule). "`FindSet(true)` takes a LockTable and the two-parameter signature is obsolete" (subtle platform behaviour + outdated training data). "CodeCop AA0233 flags `FindFirst … Next` loops" (rule-specific). + +Poor fit: "Use HTTPS instead of HTTP." "Don't hardcode secrets." "Keep transactions short." These are true but any capable LLM already applies them without prompting. + +The practical consequence: when a code-review agent flags something it shouldn't have, or misses something it should have caught, the remedy is a new knowledge file. When it already behaves correctly on a topic, no file is needed. + ## What's in this repo BCQuality contains **knowledge** and **skills**. It does not contain agents. Agents that consume BCQuality ship with [AL-Go](https://github.com/microsoft/AL-Go) and other orchestrators. @@ -38,7 +58,7 @@ Skills define how agents consume knowledge. They come in three flavors: READ and DO are read on demand — typically when the first dispatched action skill runs. They are not prerequisites for invoking Entry. WRITE is only used when scaffolding new content. -- **Action skills** — concrete skills that follow the Action Skill template to do real work (review code, audit telemetry, etc.). Action skills live inside the layers that own them (`/microsoft/skills/`, `/community/skills/`, `/custom/skills/`). An action skill is either a **leaf** that evaluates knowledge files directly, or a **super-skill** that composes other action skills (declared via `sub-skills` in frontmatter). The canonical reference is [`microsoft/skills/al-code-review.md`](microsoft/skills/al-code-review.md) (super-skill), which composes [`microsoft/skills/al-performance-review.md`](microsoft/skills/al-performance-review.md) and [`microsoft/skills/al-security-review.md`](microsoft/skills/al-security-review.md) (leaves). +- **Action skills** — concrete skills that follow the Action Skill template to do real work (review code, audit telemetry, etc.). Action skills live inside the layers that own them (`/microsoft/skills/`, `/community/skills/`, `/custom/skills/`). An action skill is either a **leaf** that evaluates knowledge files directly, or a **super-skill** that composes other action skills (declared via `sub-skills` in frontmatter). The canonical reference is [`microsoft/skills/review/al-code-review.md`](microsoft/skills/review/al-code-review.md) (super-skill), which composes six leaf skills under [`microsoft/skills/review/`](microsoft/skills/review/) — one per knowledge domain (performance, security, privacy, upgrade, style, UI). ### Agent bootstrapping @@ -52,7 +72,7 @@ Every knowledge file is a markdown file with mandatory YAML frontmatter. Files t ```yaml --- -bc-version: [26..28] # BC versions this applies to +bc-version: [all] # or [26..28] for version-gated guidance domain: performance # security | performance | ux | telemetry | ... keywords: [query, filtering, partial] # free-text tags for retrieval technologies: [al] # al | javascript | powershell | ... @@ -98,6 +118,8 @@ Action skills follow a four-step pattern: Every action skill produces output in a common format that orchestrators can consume without skill-specific parsing. The format is JSON and includes an `outcome` (so a clean run, a not-applicable skill, and a partial failure are all distinguishable), `findings` (what the skill observed), structured `references` back to the knowledge files that informed each finding, per-finding `confidence`, and a `suppressed` list recording any knowledge files overridden by layer precedence. This contract is defined in the Action Skill meta-skill so that orchestrators and action skills remain independently evolvable. +BCQuality is an **additive** knowledge layer: it augments the agent's review judgement, it does not replace it. Super-skills (such as `al-code-review`) run a self-review pass alongside their sub-skills and surface concerns the agent identified on its own, marked with `from-sub-skill: "agent"` and an empty `references: []` so consumers can render them distinctly from knowledge-backed findings. See [agent-consumption.md](agent-consumption.md) and [`skills/do.md`](skills/do.md) for the full contract. + The meta-skills in `/skills/` define this pattern. Every concrete action skill follows it. For the end-to-end flow — from orchestrator trigger through to how output reaches developers — see [agent-consumption.md](agent-consumption.md). diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..e751608 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,14 @@ + + +## Security + +Microsoft takes the security of our software products and services seriously, which +includes all source code repositories in our GitHub organizations. + +**Please do not report security vulnerabilities through public GitHub issues.** + +For security reporting information, locations, contact information, and policies, +please review the latest guidance for Microsoft repositories at +[https://aka.ms/SECURITY.md](https://aka.ms/SECURITY.md). + + \ No newline at end of file diff --git a/agent-consumption.md b/agent-consumption.md index 6ee07b7..cbeb07f 100644 --- a/agent-consumption.md +++ b/agent-consumption.md @@ -35,7 +35,7 @@ The agent reads `/skills/entry.md` and runs it against the task context. Entry a The dispatch record names one or more action skills and the subset of inputs each should receive. If the outcome is `no-match` or `failed`, the agent returns the record to the orchestrator unchanged. ### 4. Agent invokes each dispatched action skill -Action skills live inside the layers — `/microsoft/skills/`, `/community/skills/`, `/custom/skills/` — so their authority is carried by their location. For a PR review, Entry typically dispatches `microsoft/skills/al-code-review.md`. The agent reads the file and executes it. +Action skills live inside the layers — `/microsoft/skills/`, `/community/skills/`, `/custom/skills/` — so their authority is carried by their location. For a PR review, Entry typically dispatches `microsoft/skills/review/al-code-review.md`. The agent reads the file and executes it. ### 5. Action skill executes the four-step pattern @@ -66,6 +66,17 @@ The orchestrator parses this **without skill-specific logic**. This is the point ### 7. Orchestrator integrates The orchestrator turns findings into PR comments, build gates, or IDE diagnostics, and links the references back to the knowledge files so the PR author — human or agent — can read the guidance. +## Knowledge-backed and agent findings + +BCQuality is an **additive** knowledge layer. The agent surfaces two kinds of findings, both shaped to the same DO output contract: + +- **Knowledge-backed findings** carry one or more entries in `references[]` pointing at BCQuality knowledge files. Their `id` is the primary file's repo-relative path. These are produced by leaf sub-skills and rolled up by super-skills. +- **Agent findings** are surfaced by a super-skill from its own self-review pass when no BCQuality knowledge file backs the concern. They are tagged with `from-sub-skill: "agent"`, carry an empty `references: []`, use a slug `id` prefixed `agent:`, and have `confidence` capped at `medium`. Their `message` is self-contained because there is no knowledge-file footer to fall back on. + +Before a super-skill emits an agent finding, it validates the candidate against the BCQuality knowledge already loaded for the task: a matching file upgrades the candidate to a knowledge-backed finding (and merges or deduplicates against the relevant sub-skill output); a contradicting file suppresses the candidate. Only candidates with no BCQuality coverage become agent findings. + +Orchestrators MAY render the two kinds differently — for example, by labelling agent findings or routing them to a separate review domain — and MAY apply independent severity floors. The `from-sub-skill: "agent"` marker is the contract. + ## Why this architecture - **Entry is the only hardcoded thing.** Orchestrators ship with one convention — *"invoke `/skills/entry.md` first"* — and nothing else. New action skills and new knowledge files are picked up automatically because Entry discovers them at dispatch time. diff --git a/community/knowledge/performance/avoid-growing-globals-in-singleinstance-subscribers.md b/community/knowledge/performance/avoid-growing-globals-in-singleinstance-subscribers.md index d2de14e..966e0dd 100644 --- a/community/knowledge/performance/avoid-growing-globals-in-singleinstance-subscribers.md +++ b/community/knowledge/performance/avoid-growing-globals-in-singleinstance-subscribers.md @@ -1,5 +1,5 @@ --- -bc-version: [26..28] +bc-version: [all] domain: performance keywords: [singleinstance, subscriber, event, memory, session] technologies: [al] @@ -9,7 +9,7 @@ application-area: [all] # Avoid growing globals in SingleInstance subscribers -> **Seed article.** Ported from BC Code Intelligence to seed the community corpus. Community contributors are invited to expand or refine. +> Contributions welcome — open a PR to refine or extend this article. ## Description diff --git a/community/knowledge/performance/choose-maintainsiftindex-by-read-write-ratio.md b/community/knowledge/performance/choose-maintainsiftindex-by-read-write-ratio.md index 787c344..f320dbc 100644 --- a/community/knowledge/performance/choose-maintainsiftindex-by-read-write-ratio.md +++ b/community/knowledge/performance/choose-maintainsiftindex-by-read-write-ratio.md @@ -1,5 +1,5 @@ --- -bc-version: [26..28] +bc-version: [all] domain: performance keywords: [maintainsiftindex, sift, calcsums, flowfield, write-cost] technologies: [al] @@ -9,7 +9,7 @@ application-area: [all] # Choose MaintainSIFTIndex by read-write ratio -> **Seed article.** Ported from BC Code Intelligence to seed the community corpus. Community contributors are invited to expand or refine. +> Contributions welcome — open a PR to refine or extend this article. ## Description diff --git a/community/knowledge/performance/load-common-fields-before-branching-on-case.md b/community/knowledge/performance/load-common-fields-before-branching-on-case.md index 1cc8492..f91d72e 100644 --- a/community/knowledge/performance/load-common-fields-before-branching-on-case.md +++ b/community/knowledge/performance/load-common-fields-before-branching-on-case.md @@ -1,5 +1,5 @@ --- -bc-version: [26..28] +bc-version: [all] domain: performance keywords: [setloadfields, case, conditional, branch, field-loading] technologies: [al] @@ -9,7 +9,7 @@ application-area: [all] # Load common fields before branching on case -> **Seed article.** Ported from BC Code Intelligence to seed the community corpus. Community contributors are invited to expand or refine. +> Contributions welcome — open a PR to refine or extend this article. ## Description diff --git a/community/knowledge/performance/load-only-primary-key-fields-for-reference-work.md b/community/knowledge/performance/load-only-primary-key-fields-for-reference-work.md index f13f444..533ab8c 100644 --- a/community/knowledge/performance/load-only-primary-key-fields-for-reference-work.md +++ b/community/knowledge/performance/load-only-primary-key-fields-for-reference-work.md @@ -1,5 +1,5 @@ --- -bc-version: [26..28] +bc-version: [all] domain: performance keywords: [setloadfields, primary-key, reference, existence-check, memory] technologies: [al] @@ -9,7 +9,7 @@ application-area: [all] # Load only primary key fields for reference work -> **Seed article.** Ported from BC Code Intelligence to seed the community corpus. Community contributors are invited to expand or refine. +> Contributions welcome — open a PR to refine or extend this article. ## Description diff --git a/community/knowledge/performance/omit-filter-only-fields-from-setloadfields.md b/community/knowledge/performance/omit-filter-only-fields-from-setloadfields.md index 3674836..c475f1b 100644 --- a/community/knowledge/performance/omit-filter-only-fields-from-setloadfields.md +++ b/community/knowledge/performance/omit-filter-only-fields-from-setloadfields.md @@ -1,5 +1,5 @@ --- -bc-version: [26..28] +bc-version: [all] domain: performance keywords: [setloadfields, filter, field-exclusion, index] technologies: [al] @@ -9,7 +9,7 @@ application-area: [all] # Omit filter-only fields from SetLoadFields -> **Seed article.** Ported from BC Code Intelligence to seed the community corpus. Community contributors are invited to expand or refine. +> Contributions welcome — open a PR to refine or extend this article. ## Description diff --git a/community/knowledge/performance/order-case-branches-by-frequency.md b/community/knowledge/performance/order-case-branches-by-frequency.md index 90518f6..5768004 100644 --- a/community/knowledge/performance/order-case-branches-by-frequency.md +++ b/community/knowledge/performance/order-case-branches-by-frequency.md @@ -1,5 +1,5 @@ --- -bc-version: [26..28] +bc-version: [all] domain: performance keywords: [case, branch, frequency, control-flow, hot-path] technologies: [al] @@ -9,7 +9,7 @@ application-area: [all] # Order case branches by frequency -> **Seed article.** Ported from BC Code Intelligence to seed the community corpus. Community contributors are invited to expand or refine. +> Contributions welcome — open a PR to refine or extend this article. ## Description diff --git a/community/knowledge/performance/use-deleteall-for-filtered-bulk-deletion.md b/community/knowledge/performance/use-deleteall-for-filtered-bulk-deletion.md index 194dae4..0c5a1de 100644 --- a/community/knowledge/performance/use-deleteall-for-filtered-bulk-deletion.md +++ b/community/knowledge/performance/use-deleteall-for-filtered-bulk-deletion.md @@ -1,5 +1,5 @@ --- -bc-version: [26..28] +bc-version: [all] domain: performance keywords: [deleteall, bulk-delete, sql, ondelete, trigger-bypass] technologies: [al] @@ -9,7 +9,7 @@ application-area: [all] # Use DeleteAll for filtered bulk deletion -> **Seed article.** Ported from BC Code Intelligence to seed the community corpus. Community contributors are invited to expand or refine. +> Contributions welcome — open a PR to refine or extend this article. ## Description diff --git a/community/knowledge/security/classify-every-field-with-dataclassification.md b/community/knowledge/security/classify-every-field-with-dataclassification.md index 540f64b..1b854aa 100644 --- a/community/knowledge/security/classify-every-field-with-dataclassification.md +++ b/community/knowledge/security/classify-every-field-with-dataclassification.md @@ -1,5 +1,5 @@ --- -bc-version: [26..28] +bc-version: [all] domain: security keywords: [dataclassification, gdpr, privacy, euii, compliance] technologies: [al] @@ -9,20 +9,18 @@ application-area: [all] # Classify every field with DataClassification -> **Seed article.** Ported from BC Code Intelligence to seed the community corpus. Community contributors are invited to expand or refine. - ## 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/community/knowledge/security/compose-permission-sets-with-included-sets.md b/community/knowledge/security/compose-permission-sets-with-included-sets.md index 3de55d0..7fb94cb 100644 --- a/community/knowledge/security/compose-permission-sets-with-included-sets.md +++ b/community/knowledge/security/compose-permission-sets-with-included-sets.md @@ -1,5 +1,5 @@ --- -bc-version: [26..28] +bc-version: [all] domain: security keywords: [permissionset, includedpermissionsets, assignable, composition, role] technologies: [al] @@ -9,7 +9,7 @@ application-area: [all] # Compose permission sets with IncludedPermissionSets -> **Seed article.** Ported from BC Code Intelligence to seed the community corpus. Community contributors are invited to expand or refine. +> Contributions welcome — open a PR to refine or extend this article. ## Description diff --git a/community/knowledge/security/do-not-grant-rights-beyond-a-users-entitlement.md b/community/knowledge/security/do-not-grant-rights-beyond-a-users-entitlement.md index dfc2666..5334ab7 100644 --- a/community/knowledge/security/do-not-grant-rights-beyond-a-users-entitlement.md +++ b/community/knowledge/security/do-not-grant-rights-beyond-a-users-entitlement.md @@ -1,5 +1,5 @@ --- -bc-version: [26..28] +bc-version: [all] domain: security keywords: [entitlement, permissionset, license, clipping, sandbox-drift] technologies: [al] @@ -9,7 +9,7 @@ application-area: [all] # Do not grant rights beyond a user's entitlement -> **Seed article.** Ported from BC Code Intelligence to seed the community corpus. Community contributors are invited to expand or refine. +> Contributions welcome — open a PR to refine or extend this article. ## Description diff --git a/community/knowledge/security/guard-bulk-operations-with-istemporary.md b/community/knowledge/security/guard-bulk-operations-with-istemporary.md index baf2bd8..7cb53f8 100644 --- a/community/knowledge/security/guard-bulk-operations-with-istemporary.md +++ b/community/knowledge/security/guard-bulk-operations-with-istemporary.md @@ -1,5 +1,5 @@ --- -bc-version: [26..28] +bc-version: [all] domain: security keywords: [istemporary, deleteall, modifyall, safeguard, precondition] technologies: [al] @@ -9,7 +9,7 @@ application-area: [all] # Guard bulk operations with IsTemporary -> **Seed article.** Ported from BC Code Intelligence to seed the community corpus. Community contributors are invited to expand or refine. +> Contributions welcome — open a PR to refine or extend this article. ## Description diff --git a/community/knowledge/security/prefer-oauth2-over-api-keys-for-external-http-calls.md b/community/knowledge/security/prefer-oauth2-over-api-keys-for-external-http-calls.md index ab30675..f12a4f9 100644 --- a/community/knowledge/security/prefer-oauth2-over-api-keys-for-external-http-calls.md +++ b/community/knowledge/security/prefer-oauth2-over-api-keys-for-external-http-calls.md @@ -1,5 +1,5 @@ --- -bc-version: [26..28] +bc-version: [all] domain: security keywords: [oauth2, api-key, authentication, httpclient, token-refresh] technologies: [al] @@ -9,7 +9,7 @@ application-area: [all] # Prefer OAuth2 over API keys for external HTTP calls -> **Seed article.** Ported from BC Code Intelligence to seed the community corpus. Community contributors are invited to expand or refine. +> Contributions welcome — open a PR to refine or extend this article. ## Description diff --git a/community/knowledge/security/protect-sensitive-data-in-temporary-tables.md b/community/knowledge/security/protect-sensitive-data-in-temporary-tables.md index e6d475d..3f4db02 100644 --- a/community/knowledge/security/protect-sensitive-data-in-temporary-tables.md +++ b/community/knowledge/security/protect-sensitive-data-in-temporary-tables.md @@ -1,5 +1,5 @@ --- -bc-version: [26..28] +bc-version: [all] domain: security keywords: [temporary-table, data-protection, permission, cleanup] technologies: [al] @@ -9,7 +9,7 @@ application-area: [all] # Protect sensitive data in temporary tables -> **Seed article.** Ported from BC Code Intelligence to seed the community corpus. Community contributors are invited to expand or refine. +> Contributions welcome — open a PR to refine or extend this article. ## Description diff --git a/microsoft/knowledge/performance/add-sift-keys-for-flowfields.good.al b/microsoft/knowledge/performance/add-sift-keys-for-flowfields.good.al deleted file mode 100644 index affc10d..0000000 --- a/microsoft/knowledge/performance/add-sift-keys-for-flowfields.good.al +++ /dev/null @@ -1,10 +0,0 @@ -tableextension 50118 "Perf Sample SIFTKey" extends "Cust. Ledger Entry" -{ - keys - { - key(PerfSampleOpenByCustomer; "Customer No.", Open, "Posting Date") - { - SumIndexFields = "Remaining Amt. (LCY)"; - } - } -} diff --git a/microsoft/knowledge/performance/add-sift-keys-for-flowfields.md b/microsoft/knowledge/performance/add-sift-keys-for-flowfields.md deleted file mode 100644 index e72eb2a..0000000 --- a/microsoft/knowledge/performance/add-sift-keys-for-flowfields.md +++ /dev/null @@ -1,27 +0,0 @@ ---- -bc-version: [26..28] -domain: performance -keywords: [sift, sumindexfields, flowfield, key, aa0232] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Add SIFT keys for FlowField aggregations - -> **Seed article.** Converted from an existing performance-review prompt to bootstrap the BCQuality performance corpus. Domain stewards should expand, restructure, and refine as needed. - -## Description - -CodeCop rule AA0232 checks that FlowFields backed by CalcSums or aggregation CalcFormula are supported by a key whose SumIndexFields include the summed field and whose key prefix matches the formula's filter fields. Without a SIFT key the platform falls back to a full aggregation on every read — typically invisible in development and catastrophic in production. - -## Best Practice - -For each Sum-style FlowField, ensure the source table has a key whose leading fields match the FlowField's CalcFormula WHERE clause and whose SumIndexFields list includes the summed field. Table extensions adding new FlowFields are responsible for adding the supporting key. - -See sample: `add-sift-keys-for-flowfields.good.al`. - -## Anti Pattern - -Declaring a FlowField on a hot table without checking whether a supporting SIFT key exists ships a latent scan into every list page and report that touches the field. - diff --git a/microsoft/knowledge/performance/addloadfields-in-report-onpredataitem.bad.al b/microsoft/knowledge/performance/addloadfields-in-report-onpredataitem.bad.al new file mode 100644 index 0000000..2fd95ac --- /dev/null +++ b/microsoft/knowledge/performance/addloadfields-in-report-onpredataitem.bad.al @@ -0,0 +1,14 @@ +report 50221 "Perf Sample AddLoadFields Bad" +{ + dataset + { + // No AddLoadFields: every Cust. Ledger Entry column ships per row, even though + // only three columns feed the layout. + dataitem(CustLedgerEntry; "Cust. Ledger Entry") + { + column(CustomerNo; "Customer No.") { } + column(PostingDate; "Posting Date") { } + column(Amount; Amount) { } + } + } +} diff --git a/microsoft/knowledge/performance/use-addloadfields-in-report-layouts.good.al b/microsoft/knowledge/performance/addloadfields-in-report-onpredataitem.good.al similarity index 76% rename from microsoft/knowledge/performance/use-addloadfields-in-report-layouts.good.al rename to microsoft/knowledge/performance/addloadfields-in-report-onpredataitem.good.al index 278957d..3267418 100644 --- a/microsoft/knowledge/performance/use-addloadfields-in-report-layouts.good.al +++ b/microsoft/knowledge/performance/addloadfields-in-report-onpredataitem.good.al @@ -1,8 +1,8 @@ -report 50112 "Perf Sample AddLoadFields Good" +report 50220 "Perf Sample AddLoadFields Good" { dataset { - dataitem(Cust; "Cust. Ledger Entry") + dataitem(CustLedgerEntry; "Cust. Ledger Entry") { column(CustomerNo; "Customer No.") { } column(PostingDate; "Posting Date") { } diff --git a/microsoft/knowledge/performance/addloadfields-in-report-onpredataitem.md b/microsoft/knowledge/performance/addloadfields-in-report-onpredataitem.md new file mode 100644 index 0000000..aa811ce --- /dev/null +++ b/microsoft/knowledge/performance/addloadfields-in-report-onpredataitem.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: performance +keywords: [report, addloadfields, onpredataitem, dataitem, partial-record, layout] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# In reports, declare the fields the layout needs with AddLoadFields + +## Description + +Reports iterate dataitems on potentially large source tables and pipe rows into a layout. The partial-record optimization is the same idea as `use-setloadfields-for-partial-records.md`, but the API is different: per the upstream guidance, "for reports, use `AddLoadFields()` in `OnPreDataItem` trigger to add fields needed by the layout." `AddLoadFields` is additive — call it for each field the layout consumes — and runs once per dataitem before iteration begins. + +## Best Practice + +In each dataitem's `OnPreDataItem` trigger, list the columns the layout binds to via `AddLoadFields(, , ...)`. The platform then materializes only those columns per row. Treat the layout column list as the spec: every column the layout uses must be added; columns the layout does not use should not be added. + +See sample: `addloadfields-in-report-onpredataitem.good.al`. + +## Anti Pattern + +Relying on the dataitem's default to load every field. On a report bound to a ledger-scale table this transfers an entire row per iteration, of which the layout reads a fraction. + +See sample: `addloadfields-in-report-onpredataitem.bad.al`. diff --git a/microsoft/knowledge/performance/admin-and-migration-pages-tolerate-lower-perf.md b/microsoft/knowledge/performance/admin-and-migration-pages-tolerate-lower-perf.md new file mode 100644 index 0000000..3e40ef3 --- /dev/null +++ b/microsoft/knowledge/performance/admin-and-migration-pages-tolerate-lower-perf.md @@ -0,0 +1,22 @@ +--- +bc-version: [all] +domain: performance +keywords: [admin-page, migration, wizard, hybrid, permissions, lower-severity] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Admin and migration pages tolerate lower performance discipline + +## Description + +Some pages run rarely and against small datasets, and the upstream guidance explicitly calls for treating them as lower severity. Per the review checklist, "Admin/migration pages (`Admin`, `Setup`, `Wizard`, `Migration`, `HybridBC14`, `HybridSL`, `HybridGP` namespaces, `Permissions`/`PermissionSet` pages) are infrequently used with small datasets — apply lower severity." The same logic covers one-time wizards and tenant-bootstrap routines: the code path runs a handful of times in the lifetime of a tenant, against a bounded dataset, by an administrator. + +## Best Practice + +When triaging a finding on an admin, migration, or wizard page, downgrade severity relative to the same finding on a hot business path. A `FindSet` loop without `SetLoadFields` on a migration page that processes setup records once per tenant is a different finding than the same loop on a posting routine that runs thousands of times a day. Note this context explicitly in the review so the call site is not "fixed" twice with diminishing returns. + +## Anti Pattern + +Treating a migration wizard's per-row loop with the same urgency as the same loop in `Sales-Post`. The fix cost is the same; the production benefit is not. Bulk-rewriting an admin page to use `ModifyAll` and partial records buys nothing the user will perceive. diff --git a/microsoft/knowledge/performance/filter-before-find.bad.al b/microsoft/knowledge/performance/apply-filters-before-iterating.bad.al similarity index 60% rename from microsoft/knowledge/performance/filter-before-find.bad.al rename to microsoft/knowledge/performance/apply-filters-before-iterating.bad.al index 12d797e..6dc23df 100644 --- a/microsoft/knowledge/performance/filter-before-find.bad.al +++ b/microsoft/knowledge/performance/apply-filters-before-iterating.bad.al @@ -1,7 +1,10 @@ -codeunit 50101 "Perf Sample FilterBeforeFind Bad" +codeunit 50229 "Perf Sample FilterEarly Bad" { - procedure ProcessUsCustomers(var Customer: Record Customer) + procedure ProcessUSCustomers() + var + Customer: Record Customer; begin + // Reads every customer in the table, discards the non-US ones in AL. if Customer.FindSet() then repeat if Customer."Country/Region Code" = 'US' then @@ -11,6 +14,5 @@ codeunit 50101 "Perf Sample FilterBeforeFind Bad" local procedure ProcessCustomer(var Customer: Record Customer) begin - // per-customer work end; } diff --git a/microsoft/knowledge/performance/filter-before-find.good.al b/microsoft/knowledge/performance/apply-filters-before-iterating.good.al similarity index 67% rename from microsoft/knowledge/performance/filter-before-find.good.al rename to microsoft/knowledge/performance/apply-filters-before-iterating.good.al index a8dceda..820b102 100644 --- a/microsoft/knowledge/performance/filter-before-find.good.al +++ b/microsoft/knowledge/performance/apply-filters-before-iterating.good.al @@ -1,6 +1,8 @@ -codeunit 50100 "Perf Sample FilterBeforeFind Good" +codeunit 50228 "Perf Sample FilterEarly Good" { - procedure ProcessUsCustomers(var Customer: Record Customer) + procedure ProcessUSCustomers() + var + Customer: Record Customer; begin Customer.SetRange("Country/Region Code", 'US'); if Customer.FindSet() then @@ -11,6 +13,5 @@ codeunit 50100 "Perf Sample FilterBeforeFind Good" local procedure ProcessCustomer(var Customer: Record Customer) begin - // per-customer work end; } diff --git a/microsoft/knowledge/performance/apply-filters-before-iterating.md b/microsoft/knowledge/performance/apply-filters-before-iterating.md new file mode 100644 index 0000000..5f54c3b --- /dev/null +++ b/microsoft/knowledge/performance/apply-filters-before-iterating.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: performance +keywords: [setrange, setfilter, filter, loop, early, dataset] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Apply SetRange/SetFilter before iterating, not as an if-test inside the loop + +## Description + +A `SetRange` or `SetFilter` placed before `FindSet` narrows the result set at the database. The same condition expressed as an `if` inside the loop body filters in AL, after every row has crossed the boundary. Per the upstream guidance, "apply `SetRange`/`SetFilter` as early as possible to reduce dataset" and "more specific filters = better performance." On a production-scale table the difference is the difference between scanning a subset and scanning the whole table. + +## Best Practice + +Move every predicate that can be expressed as an equality or range filter into a `SetRange` or `SetFilter` ahead of the find. Combine with `SetCurrentKey` to choose a key whose first fields match the filter (see `setcurrentkey-aligns-key-with-filters.md`). The loop body should then contain only the work that depends on per-row state. + +See sample: `apply-filters-before-iterating.good.al`. + +## Anti Pattern + +`if Customer.FindSet() then repeat if Customer."Country/Region Code" = 'US' then ProcessCustomer(Customer); until Customer.Next() = 0;` — the loop pays for every row in the table and discards the non-matching ones in AL. The intent is the same as a `SetRange("Country/Region Code", 'US')` ahead of the find, but the cost is not. + +See sample: `apply-filters-before-iterating.bad.al`. diff --git a/microsoft/knowledge/performance/apply-guards-before-get.bad.al b/microsoft/knowledge/performance/apply-guards-before-get.bad.al new file mode 100644 index 0000000..2c0c710 --- /dev/null +++ b/microsoft/knowledge/performance/apply-guards-before-get.bad.al @@ -0,0 +1,14 @@ +codeunit 50215 "Perf Sample GuardBeforeGet Bad" +{ + procedure ResolveAllocation(var PurchaseLine: Record "Purchase Line") + var + PurchaseHeader: Record "Purchase Header"; + begin + // Wasted lookup when the line has no allocation account: the procedure + // exits below, but the header was already fetched. + PurchaseHeader.Get(PurchaseLine."Document Type", PurchaseLine."Document No."); + if PurchaseLine."Selected Alloc. Account No." = '' then + exit; + // ... + end; +} diff --git a/microsoft/knowledge/performance/apply-guards-before-get.good.al b/microsoft/knowledge/performance/apply-guards-before-get.good.al new file mode 100644 index 0000000..52141f7 --- /dev/null +++ b/microsoft/knowledge/performance/apply-guards-before-get.good.al @@ -0,0 +1,12 @@ +codeunit 50214 "Perf Sample GuardBeforeGet Good" +{ + procedure ResolveAllocation(var PurchaseLine: Record "Purchase Line") + var + PurchaseHeader: Record "Purchase Header"; + begin + if PurchaseLine."Selected Alloc. Account No." = '' then + exit; + PurchaseHeader.Get(PurchaseLine."Document Type", PurchaseLine."Document No."); + // ... + end; +} diff --git a/microsoft/knowledge/performance/apply-guards-before-get.md b/microsoft/knowledge/performance/apply-guards-before-get.md new file mode 100644 index 0000000..9e77411 --- /dev/null +++ b/microsoft/knowledge/performance/apply-guards-before-get.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: performance +keywords: [get, guard, early-exit, conditional, lookup, wasted-query] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Apply early-exit guards before calling Get + +## Description + +A `Get` (or any other database call) executed before a guard that may exit the procedure does a round-trip the procedure never uses. Per the upstream guidance, "Flag `Get()` calls that execute before a guard condition that may exit early — the DB lookup is wasted." The fix is structural: order the procedure body so cheap checks (parameter validation, in-memory field comparisons, enum tests) run first, and the database call runs only after the guards pass. + +## Best Practice + +Read the procedure top-to-bottom and place every condition that can short-circuit ahead of every database call. The check `if SomeNo = '' then exit;` belongs above `Header.Get(...)`, not below. Each guard moved upward saves one wasted query on the path that exits. + +See sample: `apply-guards-before-get.good.al`. + +## Anti Pattern + +`Record.Get(...)` at the top of a procedure followed by `if SomeField = '' then exit;`. The code reads top-down as "load the record, then decide whether we needed it" — exactly the order that wastes the query. The pattern is easy to introduce when guards are added later, defensively, without re-checking call ordering. + +See sample: `apply-guards-before-get.bad.al`. diff --git a/microsoft/knowledge/performance/avoid-calcfields-in-loops.bad.al b/microsoft/knowledge/performance/avoid-calcfields-in-loops.bad.al deleted file mode 100644 index bc98ef1..0000000 --- a/microsoft/knowledge/performance/avoid-calcfields-in-loops.bad.al +++ /dev/null @@ -1,18 +0,0 @@ -codeunit 50117 "Perf Sample CalcFieldsInLoop Bad" -{ - procedure ProcessLargeLines(var SalesHeader: Record "Sales Header"; var SalesLine: Record "Sales Line") - begin - SalesLine.SetRange("Document Type", SalesHeader."Document Type"); - SalesLine.SetRange("Document No.", SalesHeader."No."); - if SalesLine.FindSet() then - repeat - SalesHeader.CalcFields(Amount); - if SalesHeader.Amount > 1000 then - ProcessLine(SalesLine); - until SalesLine.Next() = 0; - end; - - local procedure ProcessLine(var SalesLine: Record "Sales Line") - begin - end; -} diff --git a/microsoft/knowledge/performance/avoid-calcfields-in-loops.good.al b/microsoft/knowledge/performance/avoid-calcfields-in-loops.good.al deleted file mode 100644 index c02ca11..0000000 --- a/microsoft/knowledge/performance/avoid-calcfields-in-loops.good.al +++ /dev/null @@ -1,18 +0,0 @@ -codeunit 50116 "Perf Sample CalcFieldsInLoop Good" -{ - procedure ProcessLargeLines(var SalesHeader: Record "Sales Header"; var SalesLine: Record "Sales Line") - begin - SalesHeader.CalcFields(Amount); - SalesLine.SetRange("Document Type", SalesHeader."Document Type"); - SalesLine.SetRange("Document No.", SalesHeader."No."); - if SalesLine.FindSet() then - repeat - if SalesHeader.Amount > 1000 then - ProcessLine(SalesLine); - until SalesLine.Next() = 0; - end; - - local procedure ProcessLine(var SalesLine: Record "Sales Line") - begin - end; -} diff --git a/microsoft/knowledge/performance/avoid-calcfields-in-loops.md b/microsoft/knowledge/performance/avoid-calcfields-in-loops.md deleted file mode 100644 index 1be8cfa..0000000 --- a/microsoft/knowledge/performance/avoid-calcfields-in-loops.md +++ /dev/null @@ -1,29 +0,0 @@ ---- -bc-version: [26..28] -domain: performance -keywords: [calcfields, flowfield, loop, n-plus-one] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Do not call CalcFields inside loops - -> **Seed article.** Converted from an existing performance-review prompt to bootstrap the BCQuality performance corpus. Domain stewards should expand, restructure, and refine as needed. - -## Description - -CalcFields evaluates one or more FlowFields for the current record by issuing a separate SQL aggregation. Called inside a loop over a record set, it becomes an N+1 problem: one aggregate per row. For any non-trivial set on a ledger-entry-backed FlowField this is orders of magnitude slower than the equivalent batched query. - -## Best Practice - -Move CalcFields out of the iteration. If the total is what you need, use CalcSums on the filtered parent set. If row-by-row FlowField values are needed, reshape the computation so the aggregate runs once — for example by joining against a temporary table populated in a single batched query. - -See sample: `avoid-calcfields-in-loops.good.al`. - -## Anti Pattern - -Calling CalcFields inside `repeat ... until Next() = 0` on a hot parent record is the textbook N+1 pattern. Even a modest parent set size (hundreds of rows) turns into thousands of round-trips. - -See sample: `avoid-calcfields-in-loops.bad.al`. - diff --git a/microsoft/knowledge/performance/avoid-commit-inside-loops.bad.al b/microsoft/knowledge/performance/avoid-commit-inside-loops.bad.al index c2646e6..feacfcc 100644 --- a/microsoft/knowledge/performance/avoid-commit-inside-loops.bad.al +++ b/microsoft/knowledge/performance/avoid-commit-inside-loops.bad.al @@ -1,15 +1,14 @@ codeunit 50129 "Perf Sample CommitInLoop Bad" { - procedure ReleaseAllOrders() + procedure NormalizeCustomerNames() var - SalesHeader: Record "Sales Header"; + Customer: Record Customer; begin - SalesHeader.SetRange(Status, SalesHeader.Status::Open); - if SalesHeader.FindSet() then + if Customer.FindSet(true) then repeat - SalesHeader.Status := SalesHeader.Status::Released; - SalesHeader.Modify(); + Customer.Name := UpperCase(Customer.Name); + Customer.Modify(); Commit(); - until SalesHeader.Next() = 0; + until Customer.Next() = 0; end; } diff --git a/microsoft/knowledge/performance/avoid-commit-inside-loops.good.al b/microsoft/knowledge/performance/avoid-commit-inside-loops.good.al new file mode 100644 index 0000000..afd57a2 --- /dev/null +++ b/microsoft/knowledge/performance/avoid-commit-inside-loops.good.al @@ -0,0 +1,21 @@ +codeunit 50128 "Perf Sample CommitInLoop Good" +{ + procedure NormalizeCustomerNames() + var + Customer: Record Customer; + RowsInChunk: Integer; + ChunkSize: Integer; + begin + ChunkSize := 500; + if Customer.FindSet(true) then + repeat + Customer.Name := UpperCase(Customer.Name); + Customer.Modify(); + RowsInChunk += 1; + if RowsInChunk >= ChunkSize then begin + Commit(); + RowsInChunk := 0; + end; + until Customer.Next() = 0; + end; +} diff --git a/microsoft/knowledge/performance/avoid-commit-inside-loops.md b/microsoft/knowledge/performance/avoid-commit-inside-loops.md index f8e3943..98e0c38 100644 --- a/microsoft/knowledge/performance/avoid-commit-inside-loops.md +++ b/microsoft/knowledge/performance/avoid-commit-inside-loops.md @@ -1,7 +1,7 @@ --- -bc-version: [26..28] +bc-version: [all] domain: performance -keywords: [commit, loop, transaction, lock] +keywords: [commit, loop, transaction, lock, checkpoint, codeunit-run] technologies: [al] countries: [w1] application-area: [all] @@ -9,15 +9,17 @@ application-area: [all] # Do not Commit inside loops -> **Seed article.** Converted from an existing performance-review prompt to bootstrap the BCQuality performance corpus. Domain stewards should expand, restructure, and refine as needed. +> Contributions welcome — open a PR to refine or extend this article. ## Description -Commit ends the current transaction. Calling it inside a loop produces one transaction per iteration and loses the ability to roll back the whole operation atomically. It also interferes with the platform's ability to batch write operations. The original motivation — releasing locks during a long batch — is better served by splitting the batch into explicit checkpoints that each process a bounded number of rows. +Commit ends the current write transaction. Calling it inside a per-row loop produces one transaction per iteration and loses the ability to roll back the whole operation atomically; it also interferes with the platform's ability to batch write operations. Most loops need no explicit Commit at all — AL auto-commits the enclosing code module on successful completion (see `understand-implicit-transaction-boundary.md`). When the batch is too large for one transaction, the fix is not a per-row Commit but bounded checkpoints that each process N rows. ## Best Practice -If the batch is large enough that a single transaction is untenable, process it in checkpoints driven by an outer loop that each time picks up the next N rows. Commit once per checkpoint at a clearly defined safe boundary, not inside the per-row loop. +If the batch is large enough that a single transaction is untenable, process it in checkpoints driven by an outer loop that each time picks up the next N rows. Commit once per checkpoint at a clearly defined safe boundary, not inside the per-row loop. Wrapping each chunk in `Codeunit.Run` gives the same effect with native rollback on failure — see `codeunit-run-as-atomic-sub-operation.md`. + +See sample: `avoid-commit-inside-loops.good.al`. ## Anti Pattern diff --git a/microsoft/knowledge/performance/avoid-findfirst-with-next.bad.al b/microsoft/knowledge/performance/avoid-findfirst-with-next.bad.al deleted file mode 100644 index 027ada6..0000000 --- a/microsoft/knowledge/performance/avoid-findfirst-with-next.bad.al +++ /dev/null @@ -1,14 +0,0 @@ -codeunit 50105 "Perf Sample AvoidFindFirstNext Bad" -{ - procedure EmitAllItems(var Item: Record Item) - begin - if Item.FindFirst() then - repeat - EmitItem(Item); - until Item.Next() = 0; - end; - - local procedure EmitItem(var Item: Record Item) - begin - end; -} diff --git a/microsoft/knowledge/performance/avoid-findfirst-with-next.md b/microsoft/knowledge/performance/avoid-findfirst-with-next.md deleted file mode 100644 index bdff419..0000000 --- a/microsoft/knowledge/performance/avoid-findfirst-with-next.md +++ /dev/null @@ -1,27 +0,0 @@ ---- -bc-version: [26..28] -domain: performance -keywords: [findfirst, findlast, get, next, aa0233] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Do not pair FindFirst, FindLast, or Get with Next - -> **Seed article.** Converted from an existing performance-review prompt to bootstrap the BCQuality performance corpus. Domain stewards should expand, restructure, and refine as needed. - -## Description - -CodeCop rule AA0233 flags loops that start with FindFirst, FindLast, or Get and then call Next. FindFirst and FindLast retrieve a single row and reposition the cursor; calling Next after them forces the platform to re-seek and stream the rest of the set, which is slower than the correct FindSet pattern and signals intent incorrectly to reviewers and the optimizer. - -## Best Practice - -Choose the Find variant that matches the operation: FindSet for full iteration, FindFirst or FindLast when you want exactly one row, Get when the primary key is known. Never call Next after FindFirst, FindLast, or Get. - -## Anti Pattern - -Writing `if Rec.FindFirst() then repeat ... until Rec.Next() = 0` is the canonical AA0233 offender. The loop wastes bandwidth and obscures the author's intent. - -See sample: `avoid-findfirst-with-next.bad.al`. - diff --git a/microsoft/knowledge/performance/avoid-get-inside-loop-on-large-table.bad.al b/microsoft/knowledge/performance/avoid-get-inside-loop-on-large-table.bad.al new file mode 100644 index 0000000..7ff67be --- /dev/null +++ b/microsoft/knowledge/performance/avoid-get-inside-loop-on-large-table.bad.al @@ -0,0 +1,15 @@ +codeunit 50253 "Perf Sample NPlus1 Bad" +{ + procedure SumStdCost(var BOMLine: Record "BOM Component") TotalCost: Decimal + var + Item: Record Item; + begin + if BOMLine.FindSet() then + repeat + // Full-row Item.Get per BOM line — no partial loading, no caching. + Item.Get(BOMLine."No."); + if Item."Costing Method" = Item."Costing Method"::Standard then + TotalCost += Item."Standard Cost" * BOMLine."Quantity per"; + until BOMLine.Next() = 0; + end; +} diff --git a/microsoft/knowledge/performance/avoid-get-inside-loop-on-large-table.good.al b/microsoft/knowledge/performance/avoid-get-inside-loop-on-large-table.good.al new file mode 100644 index 0000000..2bdbf65 --- /dev/null +++ b/microsoft/knowledge/performance/avoid-get-inside-loop-on-large-table.good.al @@ -0,0 +1,15 @@ +codeunit 50252 "Perf Sample NPlus1 Good" +{ + procedure SumStdCost(var BOMLine: Record "BOM Component") TotalCost: Decimal + var + Item: Record Item; + begin + Item.SetLoadFields("Costing Method", "Standard Cost"); + if BOMLine.FindSet() then + repeat + if Item.Get(BOMLine."No.") then + if Item."Costing Method" = Item."Costing Method"::Standard then + TotalCost += Item."Standard Cost" * BOMLine."Quantity per"; + until BOMLine.Next() = 0; + end; +} diff --git a/microsoft/knowledge/performance/avoid-get-inside-loop-on-large-table.md b/microsoft/knowledge/performance/avoid-get-inside-loop-on-large-table.md new file mode 100644 index 0000000..2908d3f --- /dev/null +++ b/microsoft/knowledge/performance/avoid-get-inside-loop-on-large-table.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: performance +keywords: [n-plus-one, get, findfirst, loop, inner-lookup, large-table] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Avoid Get / FindFirst inside a loop on a large inner table + +## Description + +A `Get` or `FindFirst` against a different record inside a loop body produces one database round-trip per iteration — the classic N+1 pattern. Per the upstream guidance, "Flag when a `Get()`/`FindFirst()` is called inside a loop for each record — this creates N+1 database round-trips." The cost only matters when the inner table is meaningful: lookups against temporary tables, singleton setup tables, enum-mapping tables, permission objects, or Role IDs are bounded and safe. The pattern to catch is the inner lookup that hits a production-scale table for every outer row. + +## Best Practice + +When the loop needs values from another record, lift the lookup out of the loop if the rows can be collected up front, or apply `SetLoadFields` so each inner read transfers only the columns the loop actually uses (see `use-setloadfields-for-partial-records.md`). When the inner record is small or bounded, leave the call site alone — the rule targets large-table inner lookups specifically. + +See sample: `avoid-get-inside-loop-on-large-table.good.al`. + +## Anti Pattern + +Iterating BOM lines and calling `Item.Get(BOMLine."No.")` per row to read a costing method, with no `SetLoadFields` on `Item`. Each iteration issues one query against Item (~800k rows) and pulls the entire row to read two fields. The fix is `Item.SetLoadFields("Costing Method", "Standard Cost");` ahead of the loop — still N reads, but each one transfers only the needed columns. + +See sample: `avoid-get-inside-loop-on-large-table.bad.al`. diff --git a/microsoft/knowledge/performance/avoid-recordref-in-hot-loop.bad.al b/microsoft/knowledge/performance/avoid-recordref-in-hot-loop.bad.al new file mode 100644 index 0000000..f0cee4f --- /dev/null +++ b/microsoft/knowledge/performance/avoid-recordref-in-hot-loop.bad.al @@ -0,0 +1,22 @@ +codeunit 50255 "Perf Sample RecRef Bad" +{ + procedure ProcessAllCustomerNames() + var + Customer: Record Customer; + RecRef: RecordRef; + FldRef: FieldRef; + begin + RecRef.Open(Database::Customer); + if RecRef.FindSet() then + repeat + // Table and field are fixed at compile time, but every iteration + // pays dynamic resolution cost. + FldRef := RecRef.Field(Customer.FieldNo(Name)); + ProcessName(Format(FldRef.Value)); + until RecRef.Next() = 0; + end; + + local procedure ProcessName(Name: Text) + begin + end; +} diff --git a/microsoft/knowledge/performance/avoid-recordref-in-hot-loop.good.al b/microsoft/knowledge/performance/avoid-recordref-in-hot-loop.good.al new file mode 100644 index 0000000..86dcc3d --- /dev/null +++ b/microsoft/knowledge/performance/avoid-recordref-in-hot-loop.good.al @@ -0,0 +1,16 @@ +codeunit 50254 "Perf Sample RecRef Good" +{ + procedure ProcessAllCustomerNames() + var + Customer: Record Customer; + begin + if Customer.FindSet() then + repeat + ProcessName(Customer.Name); + until Customer.Next() = 0; + end; + + local procedure ProcessName(Name: Text[100]) + begin + end; +} diff --git a/microsoft/knowledge/performance/avoid-recordref-in-hot-loop.md b/microsoft/knowledge/performance/avoid-recordref-in-hot-loop.md new file mode 100644 index 0000000..b718449 --- /dev/null +++ b/microsoft/knowledge/performance/avoid-recordref-in-hot-loop.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: performance +keywords: [recordref, fieldref, hot-loop, typed-record, metadata] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Avoid RecordRef / FieldRef in hot loops when a typed record fits + +## Description + +`RecordRef` and `FieldRef` are slower than direct typed record access — the platform resolves the table and field at runtime instead of at compile time. The trade-off is intentional: per the upstream guidance, "RecordRef/FieldRef operations are slower than direct record access, but many features REQUIRE them for generic metadata iteration (permission checks, field copying, dynamic field access)." The rule, then, is not "never use them" but "only flag when used inside a clearly unbounded hot loop (10k+ iterations) where a typed alternative exists." + +## Best Practice + +Use `RecordRef`/`FieldRef` for genuinely generic code — permission checks, field copying, table-agnostic export. When the loop target is known at compile time and the loop iterates a large number of rows, declare the typed record and access fields directly; the saved per-iteration overhead is measurable at the volumes the rule targets. + +See sample: `avoid-recordref-in-hot-loop.good.al`. + +## Anti Pattern + +`RecRef.Open(Database::Customer); if RecRef.FindSet() then repeat FldRef := RecRef.Field(Customer.FieldNo(Name)); ProcessName(FldRef.Value); until RecRef.Next() = 0;` — the table is fixed at compile time, the field is fixed at compile time, and the loop pays the dynamic-resolution cost on every iteration. The direct `Customer.Name` form does the same work without the lookup. + +See sample: `avoid-recordref-in-hot-loop.bad.al`. diff --git a/microsoft/knowledge/performance/avoid-redundant-get-when-record-already-loaded.bad.al b/microsoft/knowledge/performance/avoid-redundant-get-when-record-already-loaded.bad.al new file mode 100644 index 0000000..e7358fb --- /dev/null +++ b/microsoft/knowledge/performance/avoid-redundant-get-when-record-already-loaded.bad.al @@ -0,0 +1,21 @@ +page 50217 "Perf Sample Redundant Bad" +{ + PageType = ListPart; + SourceTable = "Assembly Line"; + + var + AssemblyLineRec: Record "Assembly Line"; + ShowWarning: Boolean; + + trigger OnAfterGetRecord() + begin + // Redundant: the platform already fetched the row into Rec. + AssemblyLineRec.Get("Document Type", "Document No.", "Line No."); + ShowWarning := CheckAvailability(AssemblyLineRec); + end; + + local procedure CheckAvailability(var AssemblyLine: Record "Assembly Line"): Boolean + begin + exit(AssemblyLine.Quantity > 0); + end; +} diff --git a/microsoft/knowledge/performance/avoid-redundant-get-when-record-already-loaded.good.al b/microsoft/knowledge/performance/avoid-redundant-get-when-record-already-loaded.good.al new file mode 100644 index 0000000..6cb60ea --- /dev/null +++ b/microsoft/knowledge/performance/avoid-redundant-get-when-record-already-loaded.good.al @@ -0,0 +1,18 @@ +page 50216 "Perf Sample Redundant Good" +{ + PageType = ListPart; + SourceTable = "Assembly Line"; + + var + ShowWarning: Boolean; + + trigger OnAfterGetRecord() + begin + ShowWarning := CheckAvailability(Rec); + end; + + local procedure CheckAvailability(var AssemblyLine: Record "Assembly Line"): Boolean + begin + exit(AssemblyLine.Quantity > 0); + end; +} diff --git a/microsoft/knowledge/performance/avoid-redundant-get-when-record-already-loaded.md b/microsoft/knowledge/performance/avoid-redundant-get-when-record-already-loaded.md new file mode 100644 index 0000000..9684769 --- /dev/null +++ b/microsoft/knowledge/performance/avoid-redundant-get-when-record-already-loaded.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: performance +keywords: [get, onaftergetrecord, redundant, page-trigger, rec, already-loaded] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Do not Get the record the page already loaded + +## Description + +A list or card page's `OnAfterGetRecord` trigger fires *because* the platform has already fetched a row into `Rec`. Calling `Get` for that same row inside the trigger repeats the read the platform just did. Per the upstream guidance, this is "redundant — record already fetched by page runtime"; the correction is "use `Rec` directly — already loaded." The waste compounds on list pages, where the trigger runs once per row displayed. + +## Best Practice + +Inside page triggers — `OnAfterGetRecord`, `OnAfterGetCurrRecord`, validation triggers — read from `Rec` (or the trigger's record parameter). The platform exposes the freshly loaded record there for exactly this purpose. Reach for `Get` only when the trigger needs a *different* record than the one being displayed. + +See sample: `avoid-redundant-get-when-record-already-loaded.good.al`. + +## Anti Pattern + +`AssemblyLineRec.Get("Document Type", "Document No.", "Line No.");` at the top of `OnAfterGetRecord`, when the trigger is on the `Assembly Line` page itself and `Rec` already holds that row. The pattern often appears when a helper that expects a record parameter is invoked from a page trigger and the author writes a `Get` to "freshen" `Rec` rather than passing `Rec` through. + +See sample: `avoid-redundant-get-when-record-already-loaded.bad.al`. diff --git a/microsoft/knowledge/performance/avoid-user-interaction-in-transactions.bad.al b/microsoft/knowledge/performance/avoid-user-interaction-in-transactions.bad.al deleted file mode 100644 index 035b26b..0000000 --- a/microsoft/knowledge/performance/avoid-user-interaction-in-transactions.bad.al +++ /dev/null @@ -1,11 +0,0 @@ -codeunit 50127 "Perf Sample UserInTxn Bad" -{ - procedure ArchiveSalesHeader(var SalesHeader: Record "Sales Header") - begin - SalesHeader.Status := SalesHeader.Status::Released; - SalesHeader.Modify(); - if not Confirm('Archive document %1?', false, SalesHeader."No.") then - exit; - SalesHeader.Delete(true); - end; -} diff --git a/microsoft/knowledge/performance/avoid-user-interaction-in-transactions.good.al b/microsoft/knowledge/performance/avoid-user-interaction-in-transactions.good.al deleted file mode 100644 index f59e635..0000000 --- a/microsoft/knowledge/performance/avoid-user-interaction-in-transactions.good.al +++ /dev/null @@ -1,14 +0,0 @@ -codeunit 50126 "Perf Sample UserInTxn Good" -{ - procedure ArchiveSalesHeader(var SalesHeader: Record "Sales Header") - begin - if not Confirm('Archive document %1?', false, SalesHeader."No.") then - exit; - DoArchive(SalesHeader); - end; - - local procedure DoArchive(var SalesHeader: Record "Sales Header") - begin - // only Insert/Modify/Delete calls happen here; no prompts - end; -} diff --git a/microsoft/knowledge/performance/avoid-user-interaction-in-transactions.md b/microsoft/knowledge/performance/avoid-user-interaction-in-transactions.md deleted file mode 100644 index a3a9041..0000000 --- a/microsoft/knowledge/performance/avoid-user-interaction-in-transactions.md +++ /dev/null @@ -1,29 +0,0 @@ ---- -bc-version: [26..28] -domain: performance -keywords: [confirm, strmenu, message, transaction, dialog] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Do not prompt the user inside a write transaction - -> **Seed article.** Converted from an existing performance-review prompt to bootstrap the BCQuality performance corpus. Domain stewards should expand, restructure, and refine as needed. - -## Description - -Confirm, StrMenu, Message, and any other user-facing dialog pauses execution while the transaction is still open. During that pause every lock held by the transaction blocks other sessions. A user who walks away from the screen can suspend business-critical tables for an unbounded period. - -## Best Practice - -Gather every user decision before the writing phase begins. Once the decisions are known, run the transaction end-to-end without prompts. - -See sample: `avoid-user-interaction-in-transactions.good.al`. - -## Anti Pattern - -Calling Confirm or StrMenu from inside an OnInsert, OnModify, or OnDelete trigger — or from any code path that has already started modifying records — blocks on user input while holding locks. - -See sample: `avoid-user-interaction-in-transactions.bad.al`. - diff --git a/microsoft/knowledge/performance/avoid-user-prompts-inside-transactions.bad.al b/microsoft/knowledge/performance/avoid-user-prompts-inside-transactions.bad.al new file mode 100644 index 0000000..ce5cf31 --- /dev/null +++ b/microsoft/knowledge/performance/avoid-user-prompts-inside-transactions.bad.al @@ -0,0 +1,18 @@ +codeunit 50239 "Perf Sample PromptInTxn Bad" +{ + procedure PostOrder(DocNo: Code[20]) + var + SalesHeader: Record "Sales Header"; + PostConfirmQst: Label 'Post this order?'; + begin + SalesHeader.LockTable(); + SalesHeader.Get(SalesHeader."Document Type"::Order, DocNo); + // Lock held while the dialog is on screen — minutes or hours. + if Confirm(PostConfirmQst) then + PostSalesOrder(SalesHeader); + end; + + local procedure PostSalesOrder(var SalesHeader: Record "Sales Header") + begin + end; +} diff --git a/microsoft/knowledge/performance/avoid-user-prompts-inside-transactions.good.al b/microsoft/knowledge/performance/avoid-user-prompts-inside-transactions.good.al new file mode 100644 index 0000000..1407d3c --- /dev/null +++ b/microsoft/knowledge/performance/avoid-user-prompts-inside-transactions.good.al @@ -0,0 +1,18 @@ +codeunit 50238 "Perf Sample PromptInTxn Good" +{ + procedure PostOrder(DocNo: Code[20]) + var + SalesHeader: Record "Sales Header"; + PostConfirmQst: Label 'Post this order?'; + begin + if not Confirm(PostConfirmQst) then + exit; + SalesHeader.LockTable(); + SalesHeader.Get(SalesHeader."Document Type"::Order, DocNo); + PostSalesOrder(SalesHeader); + end; + + local procedure PostSalesOrder(var SalesHeader: Record "Sales Header") + begin + end; +} diff --git a/microsoft/knowledge/performance/avoid-user-prompts-inside-transactions.md b/microsoft/knowledge/performance/avoid-user-prompts-inside-transactions.md new file mode 100644 index 0000000..834641a --- /dev/null +++ b/microsoft/knowledge/performance/avoid-user-prompts-inside-transactions.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: performance +keywords: [confirm, strmenu, dialog, transaction, lock, user-interaction] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Do not hold locks while waiting for the user + +## Description + +A `Confirm`, `StrMenu`, modal page, or other user prompt issued from inside a write transaction stalls the transaction — and therefore every lock it holds — until the user responds. Per the upstream guidance, "Avoid user interactions (Confirm, StrMenu) inside transactions — they hold locks while waiting for user input." The wait is bounded only by the user; meanwhile other sessions block on whatever this transaction has acquired. + +## Best Practice + +Sequence the operation so user confirmation happens *before* any database write that takes a lock the prompt holds open. The shape is: ask the user → if confirmed, acquire locks and post. `if Confirm(...) then begin SalesHeader.LockTable(); SalesHeader.Get(DocNo); PostSalesOrder(SalesHeader); end;` keeps the lock window down to the work itself. + +See sample: `avoid-user-prompts-inside-transactions.good.al`. + +## Anti Pattern + +`SalesHeader.LockTable(); SalesHeader.Get(DocNo); if Confirm('Post this order?') then ...;` — the lock is held for as long as the dialog is up. A user who steps away to lunch holds the lock for an hour, and every other session that touches that row blocks for the duration. + +See sample: `avoid-user-prompts-inside-transactions.bad.al`. diff --git a/microsoft/knowledge/performance/calcsums-instead-of-calcfields-in-loop.bad.al b/microsoft/knowledge/performance/calcsums-instead-of-calcfields-in-loop.bad.al new file mode 100644 index 0000000..48318a9 --- /dev/null +++ b/microsoft/knowledge/performance/calcsums-instead-of-calcfields-in-loop.bad.al @@ -0,0 +1,15 @@ +codeunit 50223 "Perf Sample CalcSums Bad" +{ + procedure TotalRemaining(CustomerNo: Code[20]) Total: Decimal + var + CustLedgerEntry: Record "Cust. Ledger Entry"; + begin + CustLedgerEntry.SetRange("Customer No.", CustomerNo); + // One SQL query per row over a 10M-row ledger. + if CustLedgerEntry.FindSet() then + repeat + CustLedgerEntry.CalcFields("Remaining Amount"); + Total += CustLedgerEntry."Remaining Amount"; + until CustLedgerEntry.Next() = 0; + end; +} diff --git a/microsoft/knowledge/performance/calcsums-instead-of-calcfields-in-loop.good.al b/microsoft/knowledge/performance/calcsums-instead-of-calcfields-in-loop.good.al new file mode 100644 index 0000000..2e1f367 --- /dev/null +++ b/microsoft/knowledge/performance/calcsums-instead-of-calcfields-in-loop.good.al @@ -0,0 +1,11 @@ +codeunit 50222 "Perf Sample CalcSums Good" +{ + procedure TotalRemaining(CustomerNo: Code[20]) Total: Decimal + var + CustLedgerEntry: Record "Cust. Ledger Entry"; + begin + CustLedgerEntry.SetRange("Customer No.", CustomerNo); + CustLedgerEntry.CalcSums("Remaining Amount"); + Total := CustLedgerEntry."Remaining Amount"; + end; +} diff --git a/microsoft/knowledge/performance/calcsums-instead-of-calcfields-in-loop.md b/microsoft/knowledge/performance/calcsums-instead-of-calcfields-in-loop.md new file mode 100644 index 0000000..7d0752f --- /dev/null +++ b/microsoft/knowledge/performance/calcsums-instead-of-calcfields-in-loop.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: performance +keywords: [calcfields, calcsums, loop, flowfield, n-plus-one, aggregation] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Use CalcSums to aggregate, not CalcFields inside a loop + +## Description + +`CalcFields` materializes FlowField values for one record. Each call against a persistent table is "a separate SQL query"; running it inside a `repeat ... until Next() = 0` over a large table issues one query per row on top of the iteration itself. `CalcSums` answers the same aggregation question — "give me the sum of this FlowField over the filtered set" — as a single SQL statement. Per the upstream guidance, `CalcFields` inside loops on large persistent tables is "a performance problem"; the aggregation form is `CalcSums()`. + +## Best Practice + +When the procedure totals a FlowField (or several) across a filtered set, set the filters, then call `CalcSums("Field 1", "Field 2", ...)`. The platform issues one query; the result is read off the record's FlowField slot. Single `CalcFields` outside loops is fine, and `CalcFields` on the current row in a page's `OnAfterGetRecord` or in `OnValidate` is the standard pattern — those are per-action, not per-row over a large set. + +See sample: `calcsums-instead-of-calcfields-in-loop.good.al`. + +## Anti Pattern + +`if CustLedgerEntry.FindSet() then repeat CustLedgerEntry.CalcFields("Remaining Amount"); Total += CustLedgerEntry."Remaining Amount"; until CustLedgerEntry.Next() = 0;` — exactly the upstream-flagged shape. The iteration is the cheap part; the per-row `CalcFields` is what scales linearly with table size. + +See sample: `calcsums-instead-of-calcfields-in-loop.bad.al`. diff --git a/microsoft/knowledge/performance/codeunit-run-as-atomic-sub-operation.bad.al b/microsoft/knowledge/performance/codeunit-run-as-atomic-sub-operation.bad.al new file mode 100644 index 0000000..d74dfc1 --- /dev/null +++ b/microsoft/knowledge/performance/codeunit-run-as-atomic-sub-operation.bad.al @@ -0,0 +1,12 @@ +codeunit 50144 "Perf Sample AtomicSub Bad" +{ + procedure ApplyDiscountToSelection(var Customer: Record Customer) + begin + if Customer.FindSet(true) then + repeat + Customer."Customer Price Group" := 'VIP'; + Customer.Modify(true); + Commit(); + until Customer.Next() = 0; + end; +} diff --git a/microsoft/knowledge/performance/codeunit-run-as-atomic-sub-operation.good.al b/microsoft/knowledge/performance/codeunit-run-as-atomic-sub-operation.good.al new file mode 100644 index 0000000..91d81e3 --- /dev/null +++ b/microsoft/knowledge/performance/codeunit-run-as-atomic-sub-operation.good.al @@ -0,0 +1,29 @@ +codeunit 50142 "Perf Sample AtomicSub Good" +{ + procedure ApplyDiscountToSelection(var Customer: Record Customer) + var + ApplyOne: Codeunit "Perf Sample Apply Discount"; + begin + if Customer.FindSet() then + repeat + ClearLastError(); + if not ApplyOne.Run(Customer) then + LogSkipped(Customer."No.", GetLastErrorText()); + until Customer.Next() = 0; + end; + + local procedure LogSkipped(CustomerNo: Code[20]; ErrorText: Text) + begin + end; +} + +codeunit 50143 "Perf Sample Apply Discount" +{ + TableNo = Customer; + + trigger OnRun() + begin + Rec.Validate("Customer Price Group", 'VIP'); + Rec.Modify(true); + end; +} diff --git a/microsoft/knowledge/performance/codeunit-run-as-atomic-sub-operation.md b/microsoft/knowledge/performance/codeunit-run-as-atomic-sub-operation.md new file mode 100644 index 0000000..89777f1 --- /dev/null +++ b/microsoft/knowledge/performance/codeunit-run-as-atomic-sub-operation.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: performance +keywords: [codeunit-run, atomic, rollback, transaction, sub-transaction, try-pattern, implicit-commit] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Use Codeunit.Run to bound an atomic sub-operation + +## Description + +`Codeunit.Run(ID)` is the AL-idiomatic way to run a unit of work as an atomic sub-operation with its own transactional boundary. When the return value is captured — `if Codeunit.Run(MyCodeunit) then ...` — the runtime treats the codeunit as a unit: on successful completion it performs an implicit commit of the codeunit's database changes; on error it rolls those changes back and the caller receives `false`. Per the platform reference, "any changes done to the database will be committed at the end of the codeunit, unless an error occurs." The caller decides how to react — compensate, surface an error, continue — without having to manage transactions by hand. + +## Best Practice + +When a piece of work must either complete fully or have no effect, put it in its own codeunit and invoke it via `Codeunit.Run`, capturing the return. Use `if not Codeunit.Run(X) then Error(...)` to abort and unwind; use the plain boolean branch to react to failure without aborting the caller. This replaces the SQL-style `BEGIN TRAN / COMMIT / ROLLBACK` habit with a pattern the AL runtime implements natively. Do not confuse `Codeunit.Run` with `[TryFunction]` — both catch errors, but only `Codeunit.Run` rolls back database changes on failure (see `use-tryfunction-for-error-catching-not-rollback.md`). Note that if the caller is already in a write transaction, the platform requires a `Commit()` before `Codeunit.Run` — the sub-operation cannot nest inside an open transaction (see `codeunit-run-requires-prior-commit-inside-transaction.md`). + +See sample: `codeunit-run-as-atomic-sub-operation.good.al`. + +## Anti Pattern + +Inlining the work in the caller and sprinkling `Commit()` to simulate sub-transaction boundaries. The caller's enclosing transaction is fused to the sub-work; any Commit between checkpoints survives subsequent errors, and any errors after a Commit cannot be cleanly unwound. Per-row Commits (see `avoid-commit-inside-loops.md`) are a frequent symptom. + +See sample: `codeunit-run-as-atomic-sub-operation.bad.al`. diff --git a/microsoft/knowledge/performance/codeunit-run-requires-prior-commit-inside-transaction.bad.al b/microsoft/knowledge/performance/codeunit-run-requires-prior-commit-inside-transaction.bad.al new file mode 100644 index 0000000..64cb7a2 --- /dev/null +++ b/microsoft/knowledge/performance/codeunit-run-requires-prior-commit-inside-transaction.bad.al @@ -0,0 +1,27 @@ +codeunit 50147 "Perf Sample OpenTxnRun Bad" +{ + procedure ApplyDiscountToSelection(var Customer: Record Customer) + var + ApplyOne: Codeunit "Perf Sample OpenTxnRun Apply"; + RunLog: Record "Custom Run Log"; + begin + if Customer.FindSet() then + repeat + RunLog.Init(); + RunLog."Customer No." := Customer."No."; + RunLog.Insert(); + if not ApplyOne.Run(Customer) then; + until Customer.Next() = 0; + end; +} + +codeunit 50148 "Perf Sample OpenTxnRun Apply" +{ + TableNo = Customer; + + trigger OnRun() + begin + Rec.Validate("Customer Price Group", 'VIP'); + Rec.Modify(true); + end; +} diff --git a/microsoft/knowledge/performance/codeunit-run-requires-prior-commit-inside-transaction.good.al b/microsoft/knowledge/performance/codeunit-run-requires-prior-commit-inside-transaction.good.al new file mode 100644 index 0000000..ac9fcfb --- /dev/null +++ b/microsoft/knowledge/performance/codeunit-run-requires-prior-commit-inside-transaction.good.al @@ -0,0 +1,37 @@ +codeunit 50145 "Perf Sample DeferredLog Good" +{ + procedure ApplyDiscountToSelection(var Customer: Record Customer) + var + ApplyOne: Codeunit "Perf Sample DeferredLog Apply"; + FailedCustomerNos: List of [Code[20]]; + FailureReasons: List of [Text]; + Index: Integer; + begin + if Customer.FindSet() then + repeat + ClearLastError(); + if not ApplyOne.Run(Customer) then begin + FailedCustomerNos.Add(Customer."No."); + FailureReasons.Add(GetLastErrorText()); + end; + until Customer.Next() = 0; + + for Index := 1 to FailedCustomerNos.Count() do + WriteFailureLog(FailedCustomerNos.Get(Index), FailureReasons.Get(Index)); + end; + + local procedure WriteFailureLog(CustomerNo: Code[20]; Reason: Text) + begin + end; +} + +codeunit 50146 "Perf Sample DeferredLog Apply" +{ + TableNo = Customer; + + trigger OnRun() + begin + Rec.Validate("Customer Price Group", 'VIP'); + Rec.Modify(true); + end; +} diff --git a/microsoft/knowledge/performance/codeunit-run-requires-prior-commit-inside-transaction.md b/microsoft/knowledge/performance/codeunit-run-requires-prior-commit-inside-transaction.md new file mode 100644 index 0000000..a07520f --- /dev/null +++ b/microsoft/knowledge/performance/codeunit-run-requires-prior-commit-inside-transaction.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: performance +keywords: [codeunit-run, commit, write-transaction, nesting, loop, runtime-error] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Commit before Codeunit.Run when the caller already holds a write transaction + +## Description + +`Codeunit.Run` cannot nest inside an open write transaction. Per the platform reference, "If you're already in a transaction you must commit first before calling `Codeunit.Run`." The platform enforces this at runtime: the first call dies with an error, not at compile time. The rule most often surfaces in a loop that pairs outer-scope writes — progress records, audit log entries, failure markers — with a per-item `Codeunit.Run`: the first outer write opens a transaction, the subsequent `Codeunit.Run` throws. `[CommitBehavior]` does not silence this, because the implicit commit inside `Codeunit.Run` is exempt from the attribute: "The `CommitBehavior` only applies to explicit commits, not implicit commits done as part of [Codeunit.Run]." `[TryFunction]` is not a substitute either: a try method catches errors but does not open its own rollback boundary (see `use-tryfunction-for-error-catching-not-rollback.md`). + +## Best Practice + +For the `Codeunit.Run` atomic-sub-operation pattern (see `codeunit-run-as-atomic-sub-operation.md`) to work in a loop, keep the outer scope **read-only**. Move per-iteration writes — progress updates, logging, audit entries — into the sub-codeunit so they commit or roll back together with the per-item work. If logging must live outside the atomic boundary, defer it: collect failure info in memory during the loop (a `List of [Text]`, a temporary record, local variables) and write it in one pass after the loop ends, when no outer write transaction is open. + +See sample: `codeunit-run-requires-prior-commit-inside-transaction.good.al`. + +## Anti Pattern + +Inserting `Commit()` before each `Codeunit.Run` to silence the runtime error. The error goes away, but the outer scope now commits per iteration — the behavior `avoid-commit-inside-loops.md` exists to warn against. Attempting to silence the implicit commit inside the sub-codeunit with `[CommitBehavior(CommitBehavior::Ignore)]` also fails: the attribute does not apply to `Codeunit.Run`'s implicit commit. Conditioning the Commit on `Database.IsInWriteTransaction()` (runtime 11.0+) is another version of the same trap — the method has legitimate uses for diagnostics and library code that genuinely cannot control its caller, but branching production flow on runtime transaction state typically signals unclear ownership that would be better fixed by restructuring the caller so transaction state is predictable. + +See sample: `codeunit-run-requires-prior-commit-inside-transaction.bad.al`. diff --git a/microsoft/knowledge/performance/do-not-locktable-in-read-only-procedure.bad.al b/microsoft/knowledge/performance/do-not-locktable-in-read-only-procedure.bad.al new file mode 100644 index 0000000..79c524e --- /dev/null +++ b/microsoft/knowledge/performance/do-not-locktable-in-read-only-procedure.bad.al @@ -0,0 +1,10 @@ +codeunit 50235 "Perf Sample LockReadOnly Bad" +{ + procedure GetStatus(var AgentStatus: Record "Agent Status"): Boolean + begin + // Read-only path, yet every caller's transaction now acquires UPDLOCK + // on Agent Status for the remainder of the transaction. + AgentStatus.LockTable(); + exit(AgentStatus.Get()); + end; +} diff --git a/microsoft/knowledge/performance/do-not-locktable-in-read-only-procedure.good.al b/microsoft/knowledge/performance/do-not-locktable-in-read-only-procedure.good.al new file mode 100644 index 0000000..3f2a3a3 --- /dev/null +++ b/microsoft/knowledge/performance/do-not-locktable-in-read-only-procedure.good.al @@ -0,0 +1,14 @@ +codeunit 50234 "Perf Sample LockReadOnly Good" +{ + procedure GetStatus(var AgentStatus: Record "Agent Status"): Boolean + begin + if AgentStatus.Get() then + exit(true); + AgentStatus.LockTable(); + if not AgentStatus.Get() then begin + AgentStatus.Init(); + AgentStatus.Insert(); + end; + exit(true); + end; +} diff --git a/microsoft/knowledge/performance/do-not-locktable-in-read-only-procedure.md b/microsoft/knowledge/performance/do-not-locktable-in-read-only-procedure.md new file mode 100644 index 0000000..f25af2e --- /dev/null +++ b/microsoft/knowledge/performance/do-not-locktable-in-read-only-procedure.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: performance +keywords: [locktable, read-only, helper, contention, transaction] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Do not LockTable in a read-only procedure + +## Description + +`LockTable` is a transaction-wide signal: from the call onward, every read against that table in the same transaction acquires `UPDLOCK`. Per the upstream guidance, "`LockTable()` before Modify/Insert/Delete in the same procedure is the correct pattern" — locking the read against the write that follows is what the call exists for. The anti-pattern is "`LockTable()` in read-only procedures — unnecessary lock contention": the procedure never writes, but the lock cost is paid by everyone sharing the transaction. + +## Best Practice + +Reserve `LockTable` for the read directly before a `Modify`, `Insert`, or `Delete` that depends on the read value. If a helper is sometimes called for reading and sometimes for writing, split it into separate read and write paths and call `LockTable` only on the write path. For read-only existence checks or lookups, the right primitive is `ReadIsolation` (see `prefer-readisolation-over-locktable-for-reads.md`). + +See sample: `do-not-locktable-in-read-only-procedure.good.al`. + +## Anti Pattern + +A pure getter that opens with `Rec.LockTable();`. Every caller's transaction now acquires `UPDLOCK` on that table for every subsequent read until commit. The contention shows up as blocking on unrelated sessions whose own code path looks innocent — the locker is invisible to the blocked reader. + +See sample: `do-not-locktable-in-read-only-procedure.bad.al`. diff --git a/microsoft/knowledge/performance/do-not-modify-in-onaftergetrecord.bad.al b/microsoft/knowledge/performance/do-not-modify-in-onaftergetrecord.bad.al new file mode 100644 index 0000000..944ea99 --- /dev/null +++ b/microsoft/knowledge/performance/do-not-modify-in-onaftergetrecord.bad.al @@ -0,0 +1,17 @@ +page 50247 "Perf Sample WriteScroll Bad" +{ + PageType = List; + SourceTable = Customer; + + trigger OnAfterGetRecord() + begin + // One DB write per row displayed, every time the user scrolls. + Rec."Reminder Terms Code" := CalcReminderTerms(); + Rec.Modify(); + end; + + local procedure CalcReminderTerms(): Code[10] + begin + exit(''); + end; +} diff --git a/microsoft/knowledge/performance/do-not-modify-in-onaftergetrecord.good.al b/microsoft/knowledge/performance/do-not-modify-in-onaftergetrecord.good.al new file mode 100644 index 0000000..751875c --- /dev/null +++ b/microsoft/knowledge/performance/do-not-modify-in-onaftergetrecord.good.al @@ -0,0 +1,18 @@ +page 50246 "Perf Sample WriteScroll Good" +{ + PageType = List; + SourceTable = Customer; + + var + ShowWarning: Boolean; + + trigger OnAfterGetRecord() + begin + ShowWarning := CalcWarning(); + end; + + local procedure CalcWarning(): Boolean + begin + exit(false); + end; +} diff --git a/microsoft/knowledge/performance/do-not-modify-in-onaftergetrecord.md b/microsoft/knowledge/performance/do-not-modify-in-onaftergetrecord.md new file mode 100644 index 0000000..9de0903 --- /dev/null +++ b/microsoft/knowledge/performance/do-not-modify-in-onaftergetrecord.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: performance +keywords: [page-trigger, onaftergetrecord, modify, display, scroll, db-write] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Do not Modify inside OnAfterGetRecord + +## Description + +A list page's `OnAfterGetRecord` fires once per visible row, every time the user scrolls, sorts, or refreshes. A `Modify` inside that trigger means a database write per row displayed. Per the upstream guidance, "`Modify()` here means a DB write on every scroll. Use page variables for display-only state instead." `OnAfterGetCurrRecord` (single record on selection), `OnOpenPage`, and `OnInit` fire once or at much lower frequency and tolerate one-time setup logic. + +## Best Practice + +When the trigger needs to compute display-only state per row, write the result into a page variable (a global on the page object) rather than back to the database. Reserve `Modify` for triggers that fire on an explicit user action — `OnAction`, validation triggers, `OnQueryClosePage` — where one action maps to one write. + +See sample: `do-not-modify-in-onaftergetrecord.good.al`. + +## Anti Pattern + +`trigger OnAfterGetRecord() begin Rec."Warning Flag" := CalcWarning(); Rec.Modify(); end;` — on a list page over a moderately sized table, scrolling through fifty rows produces fifty writes. The page feels slow, the table accumulates churn, and the warning flag — which is recomputed on every refresh anyway — never needed persistence. + +See sample: `do-not-modify-in-onaftergetrecord.bad.al`. diff --git a/microsoft/knowledge/performance/do-not-remove-sourcetabletemporary-from-api-page.bad.al b/microsoft/knowledge/performance/do-not-remove-sourcetabletemporary-from-api-page.bad.al new file mode 100644 index 0000000..1f99d7c --- /dev/null +++ b/microsoft/knowledge/performance/do-not-remove-sourcetabletemporary-from-api-page.bad.al @@ -0,0 +1,12 @@ +page 50249 "Perf Sample TempAPI Bad" +{ + PageType = API; + APIPublisher = 'perf'; + APIGroup = 'sample'; + APIVersion = 'v1.0'; + EntityName = 'outboxEmail'; + EntitySetName = 'outboxEmails'; + SourceTable = "Sent Email"; + // SourceTableTemporary removed — every request now hits SQL. + DelayedInsert = true; +} diff --git a/microsoft/knowledge/performance/do-not-remove-sourcetabletemporary-from-api-page.good.al b/microsoft/knowledge/performance/do-not-remove-sourcetabletemporary-from-api-page.good.al new file mode 100644 index 0000000..0ec95f7 --- /dev/null +++ b/microsoft/knowledge/performance/do-not-remove-sourcetabletemporary-from-api-page.good.al @@ -0,0 +1,12 @@ +page 50248 "Perf Sample TempAPI Good" +{ + PageType = API; + APIPublisher = 'perf'; + APIGroup = 'sample'; + APIVersion = 'v1.0'; + EntityName = 'outboxEmail'; + EntitySetName = 'outboxEmails'; + SourceTable = "Sent Email"; + SourceTableTemporary = true; + DelayedInsert = true; +} diff --git a/microsoft/knowledge/performance/do-not-remove-sourcetabletemporary-from-api-page.md b/microsoft/knowledge/performance/do-not-remove-sourcetabletemporary-from-api-page.md new file mode 100644 index 0000000..a7215be --- /dev/null +++ b/microsoft/knowledge/performance/do-not-remove-sourcetabletemporary-from-api-page.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: performance +keywords: [sourcetabletemporary, api-page, temporary, persistent, in-memory] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Removing SourceTableTemporary on an API page switches it from in-memory to persistent + +## Description + +`SourceTableTemporary = true` on a page makes the page's record buffer in-memory only — reads and writes do not touch SQL. The same applies to `TableType = Temporary` on a record. Removing either turns operations that were memory accesses into database round-trips. Per the upstream guidance, the change is "potentially increasing DB load for high-volume paths (API pages, background tasks)" — and on API pages especially, the change is invisible at the page definition but visible at production scale. + +## Best Practice + +If a page or record was declared temporary on purpose — to buffer payloads, accept synthetic rows, or expose computed data through an API surface without persisting it — keep it temporary. When removing the property looks necessary, audit the call sites first: a temporary API page is often consumed by integrations that issue many calls per minute, and the round-trip cost is paid per call. If persistence is genuinely required, weigh storage and lock cost against alternatives (a regular table the API page reads from, an event-driven write). + +See sample: `do-not-remove-sourcetabletemporary-from-api-page.good.al`. + +## Anti Pattern + +Dropping `SourceTableTemporary = true` from an API page to "simplify" it, without revisiting the access pattern. The page begins issuing real SQL on every request; locks now contend with other writers; bulk integrations slow proportionally. The same trap exists for a record that was `TableType = Temporary` and gets demoted to a persistent table to make a debugger view easier. + +See sample: `do-not-remove-sourcetabletemporary-from-api-page.bad.al`. diff --git a/microsoft/knowledge/performance/filter-before-find.md b/microsoft/knowledge/performance/filter-before-find.md deleted file mode 100644 index 94389b4..0000000 --- a/microsoft/knowledge/performance/filter-before-find.md +++ /dev/null @@ -1,29 +0,0 @@ ---- -bc-version: [26..28] -domain: performance -keywords: [filter, setrange, setfilter, findset, scan] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Filter before you find - -> **Seed article.** Converted from an existing performance-review prompt to bootstrap the BCQuality performance corpus. Domain stewards should expand, restructure, and refine as needed. - -## Description - -Every call to FindSet, Find, or FindFirst on an unfiltered record variable scans the entire table. On hot tables (ledger entries, value entries, sales invoice lines) a production dataset can easily be millions of rows, so the cost of forgetting a filter is orders of magnitude worse than the cost of applying one. - -## Best Practice - -Apply SetRange or SetFilter to narrow the record set before calling FindSet or Find. The filters should match a key on the table (see set-current-key-to-match-filters). When iterating rows that belong to a parent record, set all key-field filters before the find call — never inside the repeat loop. - -See sample: `filter-before-find.good.al`. - -## Anti Pattern - -Calling FindSet with no filters and then discarding rows inside the loop with an if-statement forces the platform to read every row of the table before your code even runs. - -See sample: `filter-before-find.bad.al`. - diff --git a/microsoft/knowledge/performance/findset-true-applies-updlock-on-read.bad.al b/microsoft/knowledge/performance/findset-true-applies-updlock-on-read.bad.al new file mode 100644 index 0000000..d80786b --- /dev/null +++ b/microsoft/knowledge/performance/findset-true-applies-updlock-on-read.bad.al @@ -0,0 +1,25 @@ +codeunit 50237 "Perf Sample FindSetTrue Bad" +{ + procedure NormalizeNames() + var + Customer: Record Customer; + begin + // Read takes a shared lock; the Modify then needs to upgrade — that gap + // is the deadlock window FindSet(true) was designed to close. + if Customer.FindSet() then + repeat + Customer.Name := UpperCase(Customer.Name); + Customer.Modify(); + until Customer.Next() = 0; + end; + + procedure ReadOnlyOverlocked() + var + Customer: Record Customer; + begin + // No Modify in the loop, yet every row is read under UpdLock. + if Customer.FindSet(true) then + repeat + until Customer.Next() = 0; + end; +} diff --git a/microsoft/knowledge/performance/findset-true-applies-updlock-on-read.good.al b/microsoft/knowledge/performance/findset-true-applies-updlock-on-read.good.al new file mode 100644 index 0000000..bb6bf91 --- /dev/null +++ b/microsoft/knowledge/performance/findset-true-applies-updlock-on-read.good.al @@ -0,0 +1,23 @@ +codeunit 50236 "Perf Sample FindSetTrue Good" +{ + procedure NormalizeNames() + var + Customer: Record Customer; + begin + if Customer.FindSet(true) then + repeat + Customer.Name := UpperCase(Customer.Name); + Customer.Modify(); + until Customer.Next() = 0; + end; + + procedure SumBalances() Total: Decimal + var + Customer: Record Customer; + begin + if Customer.FindSet() then + repeat + Total += Customer."Balance (LCY)"; + until Customer.Next() = 0; + end; +} diff --git a/microsoft/knowledge/performance/findset-true-applies-updlock-on-read.md b/microsoft/knowledge/performance/findset-true-applies-updlock-on-read.md new file mode 100644 index 0000000..48a1deb --- /dev/null +++ b/microsoft/knowledge/performance/findset-true-applies-updlock-on-read.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: performance +keywords: [findset, updlock, readisolation, locking, modify, obsolete-syntax] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# FindSet(true) applies UpdLock on the read; the two-parameter form is obsolete + +## Description + +`FindSet()` and `FindSet(false)` are read-only — no locking. Per the upstream guidance, `FindSet(true)` "signifies the intent is to modify records" and "sets `ReadIsolation::UpdLock` on the record before finding rows." That is exactly the right shape when the loop body modifies each row: the read takes the same lock the modification will need, avoiding the deadlock window between an unlocked read and a later upgrade. The older two-parameter form `FindSet(ForUpdate, UpdateKey)` is obsolete — only the single-parameter signature should appear in new code. + +## Best Practice + +Use `FindSet(true)` only when the loop body genuinely modifies the iterated rows; use `FindSet()` (or `FindSet(false)`) when the loop only reads. Do not write `FindSet(true, true)` or `FindSet(true, false)` — the two-parameter form is the obsolete signature. + +See sample: `findset-true-applies-updlock-on-read.good.al`. + +## Anti Pattern + +`FindSet(true)` on a loop that does not modify the iterated rows takes an `UpdLock` the work does not need; competing readers and writers stall against a lock the loop never uses. The mirror anti-pattern is `FindSet()` (no parameter) on a loop that *does* modify each row — the read takes a shared lock, the `Modify` then needs to upgrade, and the gap between them is a deadlock candidate. + +See sample: `findset-true-applies-updlock-on-read.bad.al`. diff --git a/microsoft/knowledge/performance/flowfield-source-key-needs-sumindexfields.bad.al b/microsoft/knowledge/performance/flowfield-source-key-needs-sumindexfields.bad.al new file mode 100644 index 0000000..f784979 --- /dev/null +++ b/microsoft/knowledge/performance/flowfield-source-key-needs-sumindexfields.bad.al @@ -0,0 +1,15 @@ +tableextension 50226 "Perf Sample SIFT Bad Cust" extends Customer +{ + fields + { + // No SIFT key on Detailed Cust. Ledg. Entry for (Customer No.) with + // "Debit Amount" in SumIndexFields — the sum falls back to row-by-row + // aggregation over a ledger-scale table. + field(50226; "Perf Sample Total Debit"; Decimal) + { + FieldClass = FlowField; + CalcFormula = sum("Detailed Cust. Ledg. Entry"."Debit Amount" + where("Customer No." = field("No."))); + } + } +} diff --git a/microsoft/knowledge/performance/flowfield-source-key-needs-sumindexfields.good.al b/microsoft/knowledge/performance/flowfield-source-key-needs-sumindexfields.good.al new file mode 100644 index 0000000..420a84a --- /dev/null +++ b/microsoft/knowledge/performance/flowfield-source-key-needs-sumindexfields.good.al @@ -0,0 +1,23 @@ +tableextension 50224 "Perf Sample SIFT Good Ext" extends "Detailed Cust. Ledg. Entry" +{ + keys + { + key(PerfSampleByCustomer; "Customer No.", "Posting Date") + { + SumIndexFields = "Debit Amount"; + } + } +} + +tableextension 50225 "Perf Sample SIFT Good Cust" extends Customer +{ + fields + { + field(50225; "Perf Sample Total Debit"; Decimal) + { + FieldClass = FlowField; + CalcFormula = sum("Detailed Cust. Ledg. Entry"."Debit Amount" + where("Customer No." = field("No."))); + } + } +} diff --git a/microsoft/knowledge/performance/flowfield-source-key-needs-sumindexfields.md b/microsoft/knowledge/performance/flowfield-source-key-needs-sumindexfields.md new file mode 100644 index 0000000..3a63613 --- /dev/null +++ b/microsoft/knowledge/performance/flowfield-source-key-needs-sumindexfields.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: performance +keywords: [flowfield, sumindexfields, sift, key, calcformula, aa0232] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# A FlowField needs a source-table key that covers its CalcFormula + +## Description + +A FlowField is computed by SQL on demand. CodeCop AA0232 — "FlowFields should be indexed with SumIndexFields on corresponding keys" — captures the indexing requirement: the source table must declare a key that includes the fields the `CalcFormula` filters on, with the aggregated field listed in that key's `SumIndexFields`. When that alignment is in place, the platform answers `CalcFields`/`CalcSums` from SIFT; without it, the same query falls back to a row-by-row aggregation on what is often a ledger-scale table. Per the upstream guidance, "Missing SIFT indices cause performance issues on List pages." + +## Best Practice + +When introducing or changing a FlowField, walk the `CalcFormula`'s `WHERE` clause field by field and verify the source table has a key whose key fields cover those filters, with the aggregated field in `SumIndexFields`. The same applies when the destination side of the FlowField filter is a list-page column: the page filter triggers the FlowField on every visible row, and only SIFT keeps that affordable. + +See sample: `flowfield-source-key-needs-sumindexfields.good.al`. + +## Anti Pattern + +A `sum` FlowField against a large source table with no matching SIFT key. Each calculation aggregates rows directly; on a ledger-sized source the FlowField becomes the slowest column on every page that displays it. Pointing an existing FlowField's `CalcFormula` at a larger source table without verifying the new source's keys is the same trap a step removed — the upstream review guidance flags it as "CalcFormula changed to larger source table". + +See sample: `flowfield-source-key-needs-sumindexfields.bad.al`. diff --git a/microsoft/knowledge/performance/guard-event-subscribers-before-db-call.bad.al b/microsoft/knowledge/performance/guard-event-subscribers-before-db-call.bad.al new file mode 100644 index 0000000..15402a3 --- /dev/null +++ b/microsoft/knowledge/performance/guard-event-subscribers-before-db-call.bad.al @@ -0,0 +1,18 @@ +codeunit 50257 "Perf Sample EventGuard Bad" +{ + [EventSubscriber(ObjectType::Table, Database::"Sales Line", 'OnAfterValidateEvent', 'Quantity', false, false)] + local procedure OnAfterValidateQuantity(var Rec: Record "Sales Line") + var + Item: Record Item; + begin + // Item.Get fires on every Quantity edit — including lines whose Type is + // not Item. No cheap guard, no SetLoadFields. + Item.Get(Rec."No."); + if Item."Item Category Code" <> '' then + RecalculatePrice(Rec, Item); + end; + + local procedure RecalculatePrice(var SalesLine: Record "Sales Line"; var Item: Record Item) + begin + end; +} diff --git a/microsoft/knowledge/performance/guard-event-subscribers-before-db-call.good.al b/microsoft/knowledge/performance/guard-event-subscribers-before-db-call.good.al new file mode 100644 index 0000000..1530e97 --- /dev/null +++ b/microsoft/knowledge/performance/guard-event-subscribers-before-db-call.good.al @@ -0,0 +1,19 @@ +codeunit 50256 "Perf Sample EventGuard Good" +{ + [EventSubscriber(ObjectType::Table, Database::"Sales Line", 'OnAfterValidateEvent', 'Quantity', false, false)] + local procedure OnAfterValidateQuantity(var Rec: Record "Sales Line") + var + Item: Record Item; + begin + if Rec.Type <> Rec.Type::Item then + exit; + Item.SetLoadFields("Item Category Code"); + if Item.Get(Rec."No.") then + if Item."Item Category Code" <> '' then + RecalculatePrice(Rec, Item); + end; + + local procedure RecalculatePrice(var SalesLine: Record "Sales Line"; var Item: Record Item) + begin + end; +} diff --git a/microsoft/knowledge/performance/guard-event-subscribers-before-db-call.md b/microsoft/knowledge/performance/guard-event-subscribers-before-db-call.md new file mode 100644 index 0000000..c466ffe --- /dev/null +++ b/microsoft/knowledge/performance/guard-event-subscribers-before-db-call.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: performance +keywords: [event-subscriber, guard, db-call, frequently-fired, validate] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Guard event subscribers with cheap checks before any database call + +## Description + +Event subscribers fire on every event matching their signature — for `OnAfterValidateEvent` on a hot field like `Sales Line.Quantity`, that is every quantity edit by every user. Per the upstream guidance, "Keep event subscriber code lightweight" and "Avoid database operations in frequently-fired events — guard with cheap checks first." A `Get` or `FindFirst` at the top of such a subscriber pays a database round-trip on every fire, including the calls for which the subscriber's work was not needed. + +## Best Practice + +Open the subscriber with an in-memory predicate that filters out the calls the subscriber does not handle — record type, document type, status, parameter-passed flags. Only after the cheap guard passes should the body issue a database call, and only with `SetLoadFields` for the columns the body actually reads. + +See sample: `guard-event-subscribers-before-db-call.good.al`. + +## Anti Pattern + +`[EventSubscriber(...'OnAfterValidateEvent', 'Quantity', ...)] local procedure ... var Item: Record Item; begin Item.Get(Rec."No."); if Item.HasCustomPricing() then ...;` — `Item.Get` runs on every quantity change, including changes to lines whose `Type` is not `Item`. A pre-check `if Rec.Type <> Rec.Type::Item then exit;` ahead of the `Get` removes most of the calls. + +See sample: `guard-event-subscribers-before-db-call.bad.al`. diff --git a/microsoft/knowledge/performance/keep-event-subscribers-lightweight.bad.al b/microsoft/knowledge/performance/keep-event-subscribers-lightweight.bad.al deleted file mode 100644 index ea3fd43..0000000 --- a/microsoft/knowledge/performance/keep-event-subscribers-lightweight.bad.al +++ /dev/null @@ -1,12 +0,0 @@ -codeunit 50140 "Perf Sample Subscriber Bad" -{ - [EventSubscriber(ObjectType::Table, Database::"Sales Line", 'OnAfterValidateEvent', 'No.', false, false)] - local procedure HeavyWorkOnSalesLineNo(var Rec: Record "Sales Line"; var xRec: Record "Sales Line") - var - HttpClient: HttpClient; - HttpResponse: HttpResponseMessage; - begin - // synchronous external call on a hot event - HttpClient.Get('https://example.com/validate?no=' + Rec."No.", HttpResponse); - end; -} diff --git a/microsoft/knowledge/performance/keep-event-subscribers-lightweight.md b/microsoft/knowledge/performance/keep-event-subscribers-lightweight.md deleted file mode 100644 index 25e2ceb..0000000 --- a/microsoft/knowledge/performance/keep-event-subscribers-lightweight.md +++ /dev/null @@ -1,27 +0,0 @@ ---- -bc-version: [26..28] -domain: performance -keywords: [event, subscriber, publisher, extension] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Keep event subscribers lightweight - -> **Seed article.** Converted from an existing performance-review prompt to bootstrap the BCQuality performance corpus. Domain stewards should expand, restructure, and refine as needed. - -## Description - -Event subscribers run synchronously on the publisher's thread. If a subscriber does heavy work — a database query, a web service call, a layout render — every caller of the publisher pays that cost. Subscribers on hot events (OnAfterValidate on common fields, OnBeforeInsert on ledger-entry-like tables) can multiply a small per-call cost into a system-wide regression. - -## 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. - -## Anti Pattern - -Calling an external web service, running a report, or iterating a large table from inside an event subscriber on a hot publisher makes every operation on that publisher as slow as the heaviest subscriber. - -See sample: `keep-event-subscribers-lightweight.bad.al`. - diff --git a/microsoft/knowledge/performance/keep-transaction-scope-short.bad.al b/microsoft/knowledge/performance/keep-transaction-scope-short.bad.al deleted file mode 100644 index 76c7972..0000000 --- a/microsoft/knowledge/performance/keep-transaction-scope-short.bad.al +++ /dev/null @@ -1,18 +0,0 @@ -codeunit 50128 "Perf Sample TxnScope Bad" -{ - procedure ImportCustomers(var Source: List of [Text]) - var - Customer: Record Customer; - HttpClient: HttpClient; - HttpResponse: HttpResponseMessage; - Row: Text; - begin - foreach Row in Source do begin - // external call inside the write transaction - HttpClient.Get('https://example.com/validate?row=' + Row, HttpResponse); - Customer.Init(); - // ... populate from Row ... - Customer.Insert(true); - end; - end; -} diff --git a/microsoft/knowledge/performance/keep-transaction-scope-short.good.al b/microsoft/knowledge/performance/keep-transaction-scope-short.good.al deleted file mode 100644 index c875f39..0000000 --- a/microsoft/knowledge/performance/keep-transaction-scope-short.good.al +++ /dev/null @@ -1,22 +0,0 @@ -codeunit 50123 "Perf Sample TxnScope Good" -{ - procedure ImportCustomers(var Source: List of [Text]) - var - Prepared: Record Customer temporary; - Customer: Record Customer; - begin - // read, validate, and shape outside the transaction - PrepareRows(Source, Prepared); - - // transaction starts here: only Insert/Modify calls - if Prepared.FindSet() then - repeat - Customer := Prepared; - Customer.Insert(true); - until Prepared.Next() = 0; - end; - - local procedure PrepareRows(var Source: List of [Text]; var Prepared: Record Customer temporary) - begin - end; -} diff --git a/microsoft/knowledge/performance/keep-transaction-scope-short.md b/microsoft/knowledge/performance/keep-transaction-scope-short.md deleted file mode 100644 index 704cffc..0000000 --- a/microsoft/knowledge/performance/keep-transaction-scope-short.md +++ /dev/null @@ -1,29 +0,0 @@ ---- -bc-version: [26..28] -domain: performance -keywords: [transaction, lock, scope, contention] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Keep transaction scope short - -> **Seed article.** Converted from an existing performance-review prompt to bootstrap the BCQuality performance corpus. Domain stewards should expand, restructure, and refine as needed. - -## Description - -Every write operation runs inside a transaction that holds locks until the transaction ends. Long transactions increase blocking, deadlocks, and timeouts for other sessions. The same work split across narrower transactions typically completes faster under load because it holds locks for less time. - -## Best Practice - -Perform data reads, calculations, and external integrations outside the transaction whenever possible. Enter the writing phase with all inputs computed, execute the minimum set of Insert, Modify, and Delete calls, and exit. If you have a long-running batch, split it into checkpoints at safe boundaries (see avoid-commit-inside-loops). - -See sample: `keep-transaction-scope-short.good.al`. - -## Anti Pattern - -Opening a transaction, then performing external web-service calls, heavy report runs, or user-facing dialogs while the locks are held, suspends every other session that needs the same rows for as long as the external operation takes. - -See sample: `keep-transaction-scope-short.bad.al`. - diff --git a/microsoft/knowledge/performance/maintainsqlindex-false-breaks-flowfield-sift.bad.al b/microsoft/knowledge/performance/maintainsqlindex-false-breaks-flowfield-sift.bad.al new file mode 100644 index 0000000..edce742 --- /dev/null +++ b/microsoft/knowledge/performance/maintainsqlindex-false-breaks-flowfield-sift.bad.al @@ -0,0 +1,26 @@ +table 50227 "Perf Sample FA Journal Tmpl" +{ + fields + { + field(1; Name; Code[10]) { } + field(40; "No. of Lines"; Integer) + { + FieldClass = FlowField; + // Source key below has MaintainSQLIndex = false: SIFT cannot + // function, so this COUNT runs without a SQL index. + CalcFormula = count("FA Journal Line" + where("Journal Template Name" = field(Name))); + } + } +} + +tableextension 50228 "Perf Sample FA Jnl Line Ext" extends "FA Journal Line" +{ + keys + { + key(PerfSampleByTemplate; "Journal Template Name", "Journal Batch Name") + { + MaintainSQLIndex = false; + } + } +} diff --git a/microsoft/knowledge/performance/maintainsqlindex-false-breaks-flowfield-sift.md b/microsoft/knowledge/performance/maintainsqlindex-false-breaks-flowfield-sift.md new file mode 100644 index 0000000..e540859 --- /dev/null +++ b/microsoft/knowledge/performance/maintainsqlindex-false-breaks-flowfield-sift.md @@ -0,0 +1,24 @@ +--- +bc-version: [all] +domain: performance +keywords: [maintainsqlindex, key, sift, flowfield, sum, count, table-scan] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# MaintainSQLIndex = false on a key disables SIFT for FlowFields that depend on it + +## Description + +`MaintainSQLIndex = false` on a key tells the platform not to materialize that key as a SQL index. Per the upstream guidance, when a FlowField's source key carries that property, "SIFT cannot function, COUNT/SUM will table-scan." The flag is sometimes set to save write-path cost on a rarely-queried key, but if a `CalcFormula` aggregates through that exact key, the FlowField loses its index — every `CalcFields`/`CalcSums`/list-page filter that triggers it runs without one. + +## Best Practice + +When changing a key property to `MaintainSQLIndex = false`, find every FlowField whose `CalcFormula` filters on that key and verify another key covers the same fields. When adding a FlowField whose source table has only a `MaintainSQLIndex = false` key for its filter columns, add a fully-indexed key (or accept that the FlowField cannot ride SIFT and reshape the design — see `flowfield-source-key-needs-sumindexfields.md`). + +See sample: `maintainsqlindex-false-breaks-flowfield-sift.bad.al`. + +## Anti Pattern + +A FlowField whose `CalcFormula`'s `WHERE` columns line up with a key that has `MaintainSQLIndex = false`. The schema looks correct — the key exists, the SumIndexFields are listed — but at runtime the platform has no SQL index to use, and the aggregation table-scans on every invocation. diff --git a/microsoft/knowledge/performance/only-fetch-records-you-use.bad.al b/microsoft/knowledge/performance/only-fetch-records-you-use.bad.al deleted file mode 100644 index ef3401f..0000000 --- a/microsoft/knowledge/performance/only-fetch-records-you-use.bad.al +++ /dev/null @@ -1,10 +0,0 @@ -codeunit 50107 "Perf Sample OnlyFetchUsed Bad" -{ - procedure CustomerHasEntries(CustomerNo: Code[20]): Boolean - var - CustLedgerEntry: Record "Cust. Ledger Entry"; - begin - CustLedgerEntry.SetRange("Customer No.", CustomerNo); - exit(CustLedgerEntry.FindSet()); - end; -} diff --git a/microsoft/knowledge/performance/only-fetch-records-you-use.good.al b/microsoft/knowledge/performance/only-fetch-records-you-use.good.al deleted file mode 100644 index a989e28..0000000 --- a/microsoft/knowledge/performance/only-fetch-records-you-use.good.al +++ /dev/null @@ -1,10 +0,0 @@ -codeunit 50106 "Perf Sample OnlyFetchUsed Good" -{ - procedure CustomerHasEntries(CustomerNo: Code[20]): Boolean - var - CustLedgerEntry: Record "Cust. Ledger Entry"; - begin - CustLedgerEntry.SetRange("Customer No.", CustomerNo); - exit(not CustLedgerEntry.IsEmpty()); - end; -} diff --git a/microsoft/knowledge/performance/only-fetch-records-you-use.md b/microsoft/knowledge/performance/only-fetch-records-you-use.md deleted file mode 100644 index 8c45801..0000000 --- a/microsoft/knowledge/performance/only-fetch-records-you-use.md +++ /dev/null @@ -1,29 +0,0 @@ ---- -bc-version: [26..28] -domain: performance -keywords: [findset, get, aa0175, wasted-fetch, read] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Only fetch records you use - -> **Seed article.** Converted from an existing performance-review prompt to bootstrap the BCQuality performance corpus. Domain stewards should expand, restructure, and refine as needed. - -## Description - -CodeCop rule AA0175 flags code that retrieves a record and then does not use it. Every Find, FindSet, FindFirst, FindLast, or Get has a cost: the platform reads rows from SQL, materializes them, and transports them to the AL runtime. A call whose result is never read is wasted work, and on hot tables that work is never free. - -## Best Practice - -Retrieve a record only when you need one or more of its field values. When you only need to know whether at least one row matches a filter, use IsEmpty (see use-isempty-for-existence-checks). When you only need a subset of fields, use SetLoadFields (see use-setloadfields-for-partial-records). - -See sample: `only-fetch-records-you-use.good.al`. - -## Anti Pattern - -Calling FindSet or Get and then ignoring the result, or using it only as a boolean existence test, performs the full fetch and throws the data away. - -See sample: `only-fetch-records-you-use.bad.al`. - diff --git a/microsoft/knowledge/performance/pair-findset-with-next-loop.bad.al b/microsoft/knowledge/performance/pair-findset-with-next-loop.bad.al new file mode 100644 index 0000000..ec5eec3 --- /dev/null +++ b/microsoft/knowledge/performance/pair-findset-with-next-loop.bad.al @@ -0,0 +1,13 @@ +codeunit 50209 "Perf Sample FindSetNext Bad" +{ + procedure SumCustomerBalances() Total: Decimal + var + Customer: Record Customer; + begin + // AA0233: FindFirst paired with Next — single-row API used to iterate. + if Customer.FindFirst() then + repeat + Total += Customer."Balance (LCY)"; + until Customer.Next() = 0; + end; +} diff --git a/microsoft/knowledge/performance/pair-findset-with-next-loop.good.al b/microsoft/knowledge/performance/pair-findset-with-next-loop.good.al new file mode 100644 index 0000000..955c05b --- /dev/null +++ b/microsoft/knowledge/performance/pair-findset-with-next-loop.good.al @@ -0,0 +1,18 @@ +codeunit 50208 "Perf Sample FindSetNext Good" +{ + procedure SumCustomerBalances() Total: Decimal + var + Customer: Record Customer; + begin + if Customer.FindSet() then + repeat + Total += Customer."Balance (LCY)"; + until Customer.Next() = 0; + end; + + procedure GetFirstUSCustomer(var Customer: Record Customer): Boolean + begin + Customer.SetRange("Country/Region Code", 'US'); + exit(Customer.FindFirst()); + end; +} diff --git a/microsoft/knowledge/performance/pair-findset-with-next-loop.md b/microsoft/knowledge/performance/pair-findset-with-next-loop.md new file mode 100644 index 0000000..80f855c --- /dev/null +++ b/microsoft/knowledge/performance/pair-findset-with-next-loop.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: performance +keywords: [findset, findfirst, findlast, get, next, repeat-until, aa0181, aa0233] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Use FindSet with repeat..Next; do not pair FindFirst/FindLast/Get with Next + +## Description + +Two CodeCop rules carve out the loop pattern. AA0181 says `FindSet()`/`Find()` "must be used with `Next()` method" — these are the multi-row APIs that the runtime sets up for forward iteration. AA0233 says do "NOT use `FindFirst()`/`FindLast()`/`Get()` with `Next()`" — these are single-row APIs, and iterating from them "wastes CPU and bandwidth." Both rules together define one boundary: choose `FindSet` when the body iterates; choose `FindFirst`, `FindLast`, or `Get` when the body uses exactly one record. + +## Best Practice + +When the body executes `repeat ... until Next() = 0;`, open the iteration with `FindSet()`. When the body needs one record and does not call `Next`, use `FindFirst`, `FindLast`, or — if the full primary key is known — `Get` (see `use-get-instead-of-findfirst-on-full-primary-key.md`). The choice is per call site, not a global preference. + +See sample: `pair-findset-with-next-loop.good.al`. + +## Anti Pattern + +`if Customer.FindFirst() then repeat ... until Customer.Next() = 0;` — AA0233 flags this. The single-row API does not prepare the runtime for iteration, so the loop pays a cost the FindSet path does not. The mirror anti-pattern is calling `FindSet` to read a single record (see `use-isempty-for-existence-check.md` when only existence is required). + +See sample: `pair-findset-with-next-loop.bad.al`. diff --git a/microsoft/knowledge/performance/pass-false-to-insert-when-trigger-not-needed.good.al b/microsoft/knowledge/performance/pass-false-to-insert-when-trigger-not-needed.good.al new file mode 100644 index 0000000..3bcdd9a --- /dev/null +++ b/microsoft/knowledge/performance/pass-false-to-insert-when-trigger-not-needed.good.al @@ -0,0 +1,21 @@ +codeunit 50240 "Perf Sample Trigger Param Good" +{ + procedure BulkFlagOrders(var SalesHeader: Record "Sales Header") + begin + if SalesHeader.FindSet(true) then + repeat + SalesHeader."Job Queue Status" := SalesHeader."Job Queue Status"::"Scheduled for Posting"; + // Trigger has nothing to add for a status flip in this code path. + SalesHeader.Modify(false); + until SalesHeader.Next() = 0; + end; + + procedure CreateOrder(var SalesHeader: Record "Sales Header"; CustomerNo: Code[20]) + begin + SalesHeader.Init(); + SalesHeader."Document Type" := SalesHeader."Document Type"::Order; + SalesHeader."Sell-to Customer No." := CustomerNo; + // OnInsert allocates the No.-Series number — the trigger is required. + SalesHeader.Insert(true); + end; +} diff --git a/microsoft/knowledge/performance/pass-false-to-insert-when-trigger-not-needed.md b/microsoft/knowledge/performance/pass-false-to-insert-when-trigger-not-needed.md new file mode 100644 index 0000000..45214b8 --- /dev/null +++ b/microsoft/knowledge/performance/pass-false-to-insert-when-trigger-not-needed.md @@ -0,0 +1,24 @@ +--- +bc-version: [all] +domain: performance +keywords: [insert, modify, delete, trigger, run-trigger, write-parameters] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Pass false to Insert/Modify/Delete when the table triggers do not need to fire + +## Description + +`Insert(true)`, `Modify(true)`, and `Delete(true)` run the table's `OnInsert`/`OnModify`/`OnDelete` trigger; the `(false)` form skips it. Per the upstream guidance, the trigger form should be used "only when needed" — every row whose write fires a trigger pays that cost, even when the trigger has nothing useful to add for the current call site. For tight bulk write paths the difference compounds linearly with row count. + +## Best Practice + +Reach for the `(false)` form when the calling code already enforces the invariants the trigger would, or when the trigger is empty for the current table/extension. Use `(true)` when the trigger does work the caller depends on (number-series allocation, validation, cascading writes). Decide per call, not by code style: a default of "always `true`" makes bulk writes pay for triggers they did not need, and a default of "always `false`" silently skips validation the trigger was put there to enforce. + +See sample: `pass-false-to-insert-when-trigger-not-needed.good.al`. + +## Anti Pattern + +Looping over thousands of rows and calling `Modify(true)` on each, when the table's `OnModify` trigger does nothing relevant for the operation. The trigger cost is paid per row; the user-visible behavior is identical to the `(false)` form. The mirror is using `(false)` for an operation that depends on trigger-side defaulting and silently producing rows that fail downstream validation. diff --git a/microsoft/knowledge/performance/prefer-dictionary-over-temporary-table-for-lookups.md b/microsoft/knowledge/performance/prefer-dictionary-over-temporary-table-for-lookups.md new file mode 100644 index 0000000..458d098 --- /dev/null +++ b/microsoft/knowledge/performance/prefer-dictionary-over-temporary-table-for-lookups.md @@ -0,0 +1,22 @@ +--- +bc-version: [all] +domain: performance +keywords: [dictionary, temporary-table, lookup, o-of-1, key-lookup] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Prefer a Dictionary over a temporary table for pure lookups + +## Description + +A temporary table supports a full record API — filters, iteration, multi-field keys — but a pure key→value lookup pays for plumbing it does not use. Per the upstream guidance, "if a temporary table record is ONLY used as a lookup table, it is faster to use a dictionary which supports O(1) lookups instead of O(lg n) for temporary tables." The Dictionary type has no record machinery to traverse; the key hash answers the lookup directly. + +## Best Practice + +When the use of a temp record is "set a key, see if the row exists, read a single value", switch to `Dictionary of [Key, Value]`. Use the temp-table form when the use genuinely needs filtering, iteration in a specific order, or a multi-field key. Compatibility with code that expects a `Record` parameter is a real reason to keep the temp table; performance alone, on a pure lookup, is not. + +## Anti Pattern + +A temp `Record` declared, populated row by row, then queried with `SetRange(KeyField, X); if Find('=') then Value := Rec.ValueField;`. The lookup hashes the key behind the scenes and does the same work a `Dictionary` would, plus the per-row record overhead. The pattern often appears because the author originally needed iteration and the iteration was later removed without revisiting the data structure. diff --git a/microsoft/knowledge/performance/prefer-direct-record-over-recordref.bad.al b/microsoft/knowledge/performance/prefer-direct-record-over-recordref.bad.al deleted file mode 100644 index cf0ed96..0000000 --- a/microsoft/knowledge/performance/prefer-direct-record-over-recordref.bad.al +++ /dev/null @@ -1,20 +0,0 @@ -codeunit 50135 "Perf Sample RecordRef Bad" -{ - procedure BlockCustomer(CustomerNo: Code[20]) - var - RecRef: RecordRef; - PkRef: KeyRef; - NoRef: FieldRef; - BlockedRef: FieldRef; - begin - RecRef.Open(Database::Customer); - PkRef := RecRef.KeyIndex(1); - NoRef := PkRef.FieldIndex(1); - NoRef.SetRange(CustomerNo); - if not RecRef.FindFirst() then - exit; - BlockedRef := RecRef.Field(54); - BlockedRef.Value(2); - RecRef.Modify(true); - end; -} diff --git a/microsoft/knowledge/performance/prefer-direct-record-over-recordref.good.al b/microsoft/knowledge/performance/prefer-direct-record-over-recordref.good.al deleted file mode 100644 index 44ed151..0000000 --- a/microsoft/knowledge/performance/prefer-direct-record-over-recordref.good.al +++ /dev/null @@ -1,12 +0,0 @@ -codeunit 50134 "Perf Sample RecordRef Good" -{ - procedure BlockCustomer(CustomerNo: Code[20]) - var - Customer: Record Customer; - begin - if not Customer.Get(CustomerNo) then - exit; - Customer.Blocked := Customer.Blocked::All; - Customer.Modify(true); - end; -} diff --git a/microsoft/knowledge/performance/prefer-direct-record-over-recordref.md b/microsoft/knowledge/performance/prefer-direct-record-over-recordref.md deleted file mode 100644 index 9916ef8..0000000 --- a/microsoft/knowledge/performance/prefer-direct-record-over-recordref.md +++ /dev/null @@ -1,29 +0,0 @@ ---- -bc-version: [26..28] -domain: performance -keywords: [recordref, fieldref, dynamic, reflection] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Prefer direct record access over RecordRef where possible - -> **Seed article.** Converted from an existing performance-review prompt to bootstrap the BCQuality performance corpus. Domain stewards should expand, restructure, and refine as needed. - -## Description - -RecordRef and FieldRef are the platform's reflection API: they work across tables the compiler does not know at authoring time. That flexibility costs per-operation overhead — every field access goes through a lookup — and loses compile-time type checking. For operations where the table is known, a strongly-typed Record variable is simpler and faster. - -## Best Practice - -Use Record variables for code paths that target a known table. Reach for RecordRef and FieldRef only when the table is genuinely dynamic (generic export/import, field-agnostic utilities, cross-table integrations). - -See sample: `prefer-direct-record-over-recordref.good.al`. - -## Anti Pattern - -Using RecordRef as a habit, even when the target table is hardcoded two lines earlier, costs performance and hides intent from reviewers. - -See sample: `prefer-direct-record-over-recordref.bad.al`. - diff --git a/microsoft/knowledge/performance/prefer-get-for-primary-key-lookups.good.al b/microsoft/knowledge/performance/prefer-get-for-primary-key-lookups.good.al deleted file mode 100644 index a370f7d..0000000 --- a/microsoft/knowledge/performance/prefer-get-for-primary-key-lookups.good.al +++ /dev/null @@ -1,10 +0,0 @@ -codeunit 50130 "Perf Sample GetVsFind Good" -{ - procedure CustomerName(CustomerNo: Code[20]): Text[100] - var - Customer: Record Customer; - begin - if Customer.Get(CustomerNo) then - exit(Customer.Name); - end; -} diff --git a/microsoft/knowledge/performance/prefer-get-for-primary-key-lookups.md b/microsoft/knowledge/performance/prefer-get-for-primary-key-lookups.md deleted file mode 100644 index 6102d47..0000000 --- a/microsoft/knowledge/performance/prefer-get-for-primary-key-lookups.md +++ /dev/null @@ -1,29 +0,0 @@ ---- -bc-version: [26..28] -domain: performance -keywords: [get, findfirst, primary-key, lookup] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Prefer Get for primary-key lookups - -> **Seed article.** Converted from an existing performance-review prompt to bootstrap the BCQuality performance corpus. Domain stewards should expand, restructure, and refine as needed. - -## Description - -Get is a direct primary-key lookup: one index seek, one row, done. FindFirst with SetRange on the primary key fields reaches the same row through a more general code path and carries the overhead of filter setup and a broader optimizer decision. - -## Best Practice - -When the complete primary key is known, call Get. Use FindFirst only for non-primary-key lookups or when the filter is a partial prefix of the key. - -See sample: `prefer-get-for-primary-key-lookups.good.al`. - -## Anti Pattern - -Setting one SetRange per primary-key field and then calling FindFirst reproduces Get with more typing and slightly worse performance. - -See sample: `prefer-get-for-primary-key-lookups.bad.al`. - diff --git a/microsoft/knowledge/performance/prefer-modifyall-over-per-row-modify.bad.al b/microsoft/knowledge/performance/prefer-modifyall-over-per-row-modify.bad.al new file mode 100644 index 0000000..0b8c21d --- /dev/null +++ b/microsoft/knowledge/performance/prefer-modifyall-over-per-row-modify.bad.al @@ -0,0 +1,15 @@ +codeunit 50243 "Perf Sample ModifyAll Bad" +{ + procedure ApplyPriceUpdate(NewPrice: Decimal) + var + SalesLine: Record "Sales Line"; + begin + SalesLine.SetRange(Type, SalesLine.Type::Item); + // N writes when one ModifyAll would do. + if SalesLine.FindSet() then + repeat + SalesLine.Validate("Unit Price", NewPrice); + SalesLine.Modify(true); + until SalesLine.Next() = 0; + end; +} diff --git a/microsoft/knowledge/performance/prefer-modifyall-over-per-row-modify.good.al b/microsoft/knowledge/performance/prefer-modifyall-over-per-row-modify.good.al new file mode 100644 index 0000000..c33d9c0 --- /dev/null +++ b/microsoft/knowledge/performance/prefer-modifyall-over-per-row-modify.good.al @@ -0,0 +1,20 @@ +codeunit 50242 "Perf Sample ModifyAll Good" +{ + procedure ApplyPriceUpdate(NewPrice: Decimal) + var + SalesLine: Record "Sales Line"; + begin + SalesLine.SetRange(Type, SalesLine.Type::Item); + SalesLine.ModifyAll("Unit Price", NewPrice); + end; + + procedure ApplyTolerance(DocumentNo: Code[20]; ToleranceAmount: Decimal) + var + CustLedgerEntry: Record "Cust. Ledger Entry"; + begin + CustLedgerEntry.SetRange("Document No.", DocumentNo); + CustLedgerEntry.SetRange(Open, true); + CustLedgerEntry.ModifyAll("Accepted Payment Tolerance", ToleranceAmount); + CustLedgerEntry.ModifyAll("Accepted Pmt. Disc. Tolerance", false); + end; +} diff --git a/microsoft/knowledge/performance/prefer-modifyall-over-per-row-modify.md b/microsoft/knowledge/performance/prefer-modifyall-over-per-row-modify.md new file mode 100644 index 0000000..73a095c --- /dev/null +++ b/microsoft/knowledge/performance/prefer-modifyall-over-per-row-modify.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: performance +keywords: [modifyall, deleteall, bulk, loop, modify, set-based] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Use ModifyAll / DeleteAll instead of per-row Modify / Delete in a loop + +## Description + +`ModifyAll` and `DeleteAll` are the bulk APIs. Per the upstream guidance, they "execute as single SQL statements" when the table supports it — one round-trip updates or deletes every row in the filtered set. The anti-pattern is the loop equivalent: `FindSet` followed by per-row `Modify`/`Delete`, where the runtime issues one write per row. On a production-scale table the difference is the difference between a single statement and N statements. + +## Best Practice + +When the loop body does nothing more than assign a constant value (or a value computed once) to one or more fields, replace the loop with `ModifyAll("Field 1", Value1)` — and chain additional `ModifyAll` calls for additional fields. The same shape applies to `DeleteAll`. Be aware that the bulk APIs can regress to row-by-row execution for tables with certain trigger or media-field configurations (see `triggers-and-media-field-regress-modifyall.md`); when that regression applies, multiple `ModifyAll` calls become more expensive than one manual loop, so the choice is conditional, not absolute. + +See sample: `prefer-modifyall-over-per-row-modify.good.al`. + +## Anti Pattern + +`if SalesLine.FindSet() then repeat SalesLine.Validate("Unit Price", NewPrice); SalesLine.Modify(true); until SalesLine.Next() = 0;` — N writes when one would do. The pattern is easy to introduce when the loop initially does per-row computation and is later simplified to assign a constant; the loop scaffolding survives the simplification. + +See sample: `prefer-modifyall-over-per-row-modify.bad.al`. diff --git a/microsoft/knowledge/performance/prefer-readisolation-over-locktable-for-reads.bad.al b/microsoft/knowledge/performance/prefer-readisolation-over-locktable-for-reads.bad.al new file mode 100644 index 0000000..c23886c --- /dev/null +++ b/microsoft/knowledge/performance/prefer-readisolation-over-locktable-for-reads.bad.al @@ -0,0 +1,13 @@ +codeunit 50233 "Perf Sample ReadIso Bad" +{ + procedure GetOrCreate(var AgentStatus: Record "Agent Status") + begin + // LockTable poisons every subsequent read of Agent Status in the + // surrounding transaction with UPDLOCK — even for callers that only read. + AgentStatus.LockTable(); + if not AgentStatus.Get() then begin + AgentStatus.Init(); + AgentStatus.Insert(); + end; + end; +} diff --git a/microsoft/knowledge/performance/prefer-readisolation-over-locktable-for-reads.good.al b/microsoft/knowledge/performance/prefer-readisolation-over-locktable-for-reads.good.al new file mode 100644 index 0000000..be5cd55 --- /dev/null +++ b/microsoft/knowledge/performance/prefer-readisolation-over-locktable-for-reads.good.al @@ -0,0 +1,11 @@ +codeunit 50232 "Perf Sample ReadIso Good" +{ + procedure GetOrCreate(var AgentStatus: Record "Agent Status") + begin + AgentStatus.ReadIsolation := IsolationLevel::ReadCommitted; + if not AgentStatus.Get() then begin + AgentStatus.Init(); + AgentStatus.Insert(); + end; + end; +} diff --git a/microsoft/knowledge/performance/prefer-readisolation-over-locktable-for-reads.md b/microsoft/knowledge/performance/prefer-readisolation-over-locktable-for-reads.md new file mode 100644 index 0000000..c2f888b --- /dev/null +++ b/microsoft/knowledge/performance/prefer-readisolation-over-locktable-for-reads.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: performance +keywords: [readisolation, locktable, updlock, read-only, transaction-scope, isolation-level] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Prefer ReadIsolation over LockTable for read-only scenarios + +## Description + +`LockTable` and `ReadIsolation` solve different problems with different blast radii. Per the upstream guidance, "`LockTable` ensures that all READS against that table will happen with UPDLOCK for the remainder of the transaction." `ReadIsolation` "only pertains to the current record instance, while `LockTable` affects the lockstate of the entire transaction." `ReadIsolation` is also more expressive: it can heighten or lower the isolation level inside an already-established transaction. Reaching for `LockTable` when only a single read needs guarding therefore poisons every later read on that table — including reads in other code paths that share the transaction. + +## Best Practice + +For a read-only operation, or a single read that needs a higher isolation level than the surrounding transaction, set `Rec.ReadIsolation := IsolationLevel::ReadCommitted;` (or the level the call requires) immediately before the read. The hint applies only to that record instance. Save `LockTable` for code that genuinely needs every subsequent read on the table to acquire an update lock (see `findset-true-applies-updlock-on-read.md` for the alternative narrower mechanism on iterated reads). + +See sample: `prefer-readisolation-over-locktable-for-reads.good.al`. + +## Anti Pattern + +`Rec.LockTable();` at the top of a helper that only reads, perhaps to "make sure the read is consistent". Every subsequent read on that table for the rest of the transaction acquires `UPDLOCK`, including reads from unrelated code paths fused into the same transaction. The contention surfaces in unrelated user sessions, not in the helper that introduced it. + +See sample: `prefer-readisolation-over-locktable-for-reads.bad.al`. diff --git a/microsoft/knowledge/performance/production-scale-tables-warrant-extra-analysis.md b/microsoft/knowledge/performance/production-scale-tables-warrant-extra-analysis.md new file mode 100644 index 0000000..f290448 --- /dev/null +++ b/microsoft/knowledge/performance/production-scale-tables-warrant-extra-analysis.md @@ -0,0 +1,22 @@ +--- +bc-version: [all] +domain: performance +keywords: [table-size, hot-table, ledger-entry, item, customer, sales-line, scale] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Production-scale tables warrant concrete performance analysis + +## Description + +Some Business Central tables routinely reach sizes where access patterns matter much more than they do on a generic table. The upstream review guidance lists ten of them with P95 row counts: Item (~800k), Customer (~800k), Item Ledger Entry (~10M), Value Entry (~10M), G/L Entry (~10M), VAT Entry (~10M), Customer Ledger Entry (~10M), Vendor Ledger Entry (~10M), Sales Invoice Header (~300k), and Sales Invoice Line (~3M). These figures are not platform constants — they are the volumes a reviewer should assume when judging a change. + +## Best Practice + +For any code change that touches one of these tables, do not approve the pattern on intuition. Walk through the SQL the change implies (one query? one per row? one per chunk?), the memory it allocates (a `List` per row?), and the CPU work per row, against the row counts above. Smaller tables can tolerate a sub-optimal access pattern; these cannot. The rest of this domain — `apply-filters-before-iterating.md`, `use-setloadfields-for-partial-records.md`, `avoid-calcfields-in-loops.md`, `pair-findset-with-next-loop.md`, `avoid-get-inside-loop-on-persistent-tables.md` — exists primarily so that code touching these tables stays on the safe side of each rule. + +## Anti Pattern + +Generalizing from a unit test or a development tenant. A `FindSet` loop with a per-row `CalcFields` may execute in milliseconds against a few thousand rows on a developer's machine and become a multi-minute table scan against ten million Value Entry rows in production. Reasoning about performance from the dev-tenant timing instead of the production volume is the single most common way a regression ships. diff --git a/microsoft/knowledge/performance/set-current-key-to-match-filters.good.al b/microsoft/knowledge/performance/set-current-key-to-match-filters.good.al deleted file mode 100644 index a891f40..0000000 --- a/microsoft/knowledge/performance/set-current-key-to-match-filters.good.al +++ /dev/null @@ -1,9 +0,0 @@ -codeunit 50122 "Perf Sample SetCurrentKey Good" -{ - procedure LinesForDocument(DocumentType: Enum "Sales Document Type"; DocumentNo: Code[20]; var SalesLine: Record "Sales Line") - begin - SalesLine.SetCurrentKey("Document Type", "Document No.", "Line No."); - SalesLine.SetRange("Document Type", DocumentType); - SalesLine.SetRange("Document No.", DocumentNo); - end; -} diff --git a/microsoft/knowledge/performance/set-current-key-to-match-filters.md b/microsoft/knowledge/performance/set-current-key-to-match-filters.md deleted file mode 100644 index 87b4bf9..0000000 --- a/microsoft/knowledge/performance/set-current-key-to-match-filters.md +++ /dev/null @@ -1,27 +0,0 @@ ---- -bc-version: [26..28] -domain: performance -keywords: [setcurrentkey, key, index, sort, filter] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Set the current key to match your filters - -> **Seed article.** Converted from an existing performance-review prompt to bootstrap the BCQuality performance corpus. Domain stewards should expand, restructure, and refine as needed. - -## Description - -AL chooses a key for a Find call based on the current SetCurrentKey selection. When filters do not align with any key, the platform either scans or falls back to a less selective index. On tables with production-scale row counts, this is the difference between an index seek and a table scan. - -## Best Practice - -Call SetCurrentKey with the fields you filter and sort on, in the order they appear in a table key. If no suitable key exists, add one via a table extension rather than relying on an unsupported filter pattern. - -See sample: `set-current-key-to-match-filters.good.al`. - -## Anti Pattern - -Setting many filters on fields that no key covers, and leaving the key selection to the platform's heuristics, produces non-deterministic performance that degrades as the table grows. - diff --git a/microsoft/knowledge/performance/setcurrentkey-aligns-key-with-filters.good.al b/microsoft/knowledge/performance/setcurrentkey-aligns-key-with-filters.good.al new file mode 100644 index 0000000..4ab3b0a --- /dev/null +++ b/microsoft/knowledge/performance/setcurrentkey-aligns-key-with-filters.good.al @@ -0,0 +1,15 @@ +codeunit 50230 "Perf Sample SetCurrentKey Good" +{ + procedure ProcessLines(var SalesHeader: Record "Sales Header") + var + SalesLine: Record "Sales Line"; + begin + SalesLine.SetCurrentKey("Document Type", "Document No.", "Line No."); + SalesLine.SetRange("Document Type", SalesHeader."Document Type"); + SalesLine.SetRange("Document No.", SalesHeader."No."); + if SalesLine.FindSet() then + repeat + // ... + until SalesLine.Next() = 0; + end; +} diff --git a/microsoft/knowledge/performance/setcurrentkey-aligns-key-with-filters.md b/microsoft/knowledge/performance/setcurrentkey-aligns-key-with-filters.md new file mode 100644 index 0000000..f7f8180 --- /dev/null +++ b/microsoft/knowledge/performance/setcurrentkey-aligns-key-with-filters.md @@ -0,0 +1,24 @@ +--- +bc-version: [all] +domain: performance +keywords: [setcurrentkey, key, index, filter, sort] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Pick a key whose fields cover the filter and sort with SetCurrentKey + +## Description + +The platform chooses a key for each record access. When the filters or required sort do not match the primary key — or any non-explicit choice — the query may run against a key that does not cover the filter columns. Per the upstream guidance, "Use `SetCurrentKey()` to select the most efficient key for your filters" and "match key fields to your filter/sort requirements." Filtering on fields that are not in any key is flagged as bad — there is no index to ride and the access ends up reading more than necessary. + +## Best Practice + +When the access pattern is anything other than primary-key lookup, look at the filters and the desired sort, then either pick an existing key whose leading fields cover them and call `SetCurrentKey(...)`, or declare a new key on the table for the pattern. Match leading fields first — a key starting with `"Document Type", "Document No.", "Line No."` serves a filter on those three; a key starting with `"Line No."` does not. + +See sample: `setcurrentkey-aligns-key-with-filters.good.al`. + +## Anti Pattern + +Applying filters on fields that no key indexes, leaving the platform to read more than it should. The query produces the right answer; the cost surfaces only at production volume. The mirror case is forgetting `SetCurrentKey` when the wanted sort differs from the primary key — the iteration may then be sorted in memory after a wider read than necessary. diff --git a/microsoft/knowledge/performance/singleton-setup-tables-need-no-access-optimization.md b/microsoft/knowledge/performance/singleton-setup-tables-need-no-access-optimization.md new file mode 100644 index 0000000..6b32de1 --- /dev/null +++ b/microsoft/knowledge/performance/singleton-setup-tables-need-no-access-optimization.md @@ -0,0 +1,22 @@ +--- +bc-version: [all] +domain: performance +keywords: [singleton, setup-table, sales-receivables-setup, general-ledger-setup, setloadfields, bounded] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Singleton setup tables hold one row; access-pattern optimization is wasted + +## Description + +Business Central setup tables — `Sales & Receivables Setup`, `General Ledger Setup`, `FA Setup`, `Purchases & Payables Setup`, and the broader pattern of any `*Setup` table — hold at most one record per company. Per the upstream guidance, "any access pattern is fine, no `SetLoadFields` needed" on these tables. The same applies to other small bounded tables (enum mappings, permission objects, Role IDs) and system metadata tables (`TableMetadata`, `Field`, `AllObjWithCaption`) where iteration is safe. + +## Best Practice + +Skip access-pattern optimization on singleton-setup-style tables. `SalesReceivablesSetup.Get()` does not need `SetLoadFields` (see `use-setloadfields-for-partial-records.md`); a `repeat ... until` over a permission-object table does not need bulk operations. Spend the review attention on the production-scale tables instead (see `production-scale-tables-warrant-extra-analysis.md`). + +## Anti Pattern + +Mechanically applying the rules in this domain to every `Record` variable in the codebase. Flagging "missing `SetLoadFields`" on `GeneralLedgerSetup` or "use `IsEmpty` instead of `FindSet`" on a setup table adds noise without payoff — the optimization saves nothing measurable on a one-row table — and trains readers to ignore the review channel. diff --git a/microsoft/knowledge/performance/temporary-tables-have-no-database-cost.md b/microsoft/knowledge/performance/temporary-tables-have-no-database-cost.md new file mode 100644 index 0000000..d799b5f --- /dev/null +++ b/microsoft/knowledge/performance/temporary-tables-have-no-database-cost.md @@ -0,0 +1,22 @@ +--- +bc-version: [all] +domain: performance +keywords: [temporary-table, in-memory, findset, findfirst, get, no-db-cost] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Temporary tables are in-memory; access-pattern rules do not apply + +## Description + +A record declared `Temporary` (or a page with `SourceTableTemporary = true`) lives entirely in memory; reads and writes never reach SQL. Per the upstream guidance, "any access pattern (FindSet, FindFirst, Get, loops) on temp tables is acceptable — they are in-memory and fast." The rules in the rest of this domain — partial loading, bulk operations, N+1 detection, `IsEmpty` over `Count` — exist to avoid database round-trips that a temporary table does not perform. + +## Best Practice + +Recognize the `Temporary` property (on a record variable, table declaration, or page's `SourceTableTemporary`) and exempt the code from access-pattern flags. The `SetLoadFields`/`FindSet` discipline that matters for `Customer` does not matter for a temporary `Customer` variable used as a working set. The interesting performance question on a temp table is volume in memory, not query plan. + +## Anti Pattern + +Flagging a temporary table's `FindFirst` inside a loop, or a temporary table without `SetLoadFields`, as a performance issue. The recommendation produces no measurable gain and obscures genuine issues elsewhere in the same review. diff --git a/microsoft/knowledge/performance/triggers-and-media-field-regress-modifyall.md b/microsoft/knowledge/performance/triggers-and-media-field-regress-modifyall.md new file mode 100644 index 0000000..c4890a0 --- /dev/null +++ b/microsoft/knowledge/performance/triggers-and-media-field-regress-modifyall.md @@ -0,0 +1,22 @@ +--- +bc-version: [all] +domain: performance +keywords: [modifyall, deleteall, regression, triggers, media, getglobaltabletriggermask, subscriber] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Triggers, subscribers, and media fields can silently regress ModifyAll / DeleteAll + +## Description + +`ModifyAll` and `DeleteAll` usually execute as single SQL statements, but the platform falls back to a fetch-then-row-by-row loop under specific conditions. Per the upstream guidance, the regression is triggered by any of: global database triggers defined via `GetGlobalTableTriggerMask` or `GetDatabaseTableTriggerSetup` (so that `OnDatabaseDelete`/`OnGlobalDelete` must run); event subscribers on the table's `OnBeforeDelete`/`OnAfterDelete` (for `DeleteAll`) or `OnBeforeModify`/`OnAfterModify` (for `ModifyAll`); or "adding a Media or MediaSet table field to either the table or table extension." Each of these forces the platform to materialize each affected row in AL. + +## Best Practice + +Before introducing any of the above on a table — a global trigger registration, a `Modify`/`Delete` subscriber, a media or media-set field — note every `ModifyAll`/`DeleteAll` that targets the table and assess whether the regression cost is acceptable. The upstream guidance is explicit: "There should be a very good reason for doing any of the above since they will significantly regress performance of `ModifyAll` and/or `DeleteAll`." Once a table has regressed, multiple `ModifyAll` calls each iterate the rows themselves, so consolidating to one explicit `FindSet`+`Modify` loop becomes faster than chaining several `ModifyAll` calls. + +## Anti Pattern + +Adding a media field to a hot table — or subscribing to its modify/delete events from a generic logging codeunit — without auditing the bulk-write call sites. The schema change is mechanical; the performance change is invisible at the call site and only surfaces when a previously fast `ModifyAll` starts paying the per-row trigger cost in production. The mirror anti-pattern is chaining several `ModifyAll` calls on a table that has already regressed; each one re-iterates the same rows. diff --git a/microsoft/knowledge/performance/understand-implicit-transaction-boundary.md b/microsoft/knowledge/performance/understand-implicit-transaction-boundary.md new file mode 100644 index 0000000..da9a1eb --- /dev/null +++ b/microsoft/knowledge/performance/understand-implicit-transaction-boundary.md @@ -0,0 +1,22 @@ +--- +bc-version: [all] +domain: performance +keywords: [commit, transaction, implicit-commit, write-transaction, runtime, boundary] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# AL auto-commits when code execution completes + +## Description + +In AL, write transactions are managed by the runtime, not by the developer. When AL code begins executing from an entry point — an outermost trigger, a codeunit invoked via `Codeunit.Run`, a report, a page action — the runtime opens a write transaction on the first database write. When that execution completes without error, the runtime commits automatically; if that execution errors, uncommitted writes are rolled back. Explicit `Commit()` is not how write transactions are *started*; it is how a single execution is *split* into multiple transactions. Per the platform reference, "The Commit method separates write transactions in an AL code module." + +## Best Practice + +Default to no explicit `Commit()`. Let the runtime open and close the transaction around the execution. Reach for `Commit()` only when the execution has a real reason to persist partial progress — for example, a long batch that must release locks between checkpoints (see `avoid-commit-inside-loops.md`), or work that calls an external service and must persist the resulting handle before continuing with operations that may fail independently. If a stretch of work needs to either complete fully or have no effect, prefer `Codeunit.Run` over manual Commit choreography (see `codeunit-run-as-atomic-sub-operation.md`). + +## Anti Pattern + +Sprinkling `Commit()` defensively — at the end of a procedure, after every Modify, or "just to be safe" — reflects a SQL-style mental model that does not apply here. Every stray Commit shortens the rollback window: work before the Commit survives later errors the developer almost certainly intended to unwind. A Commit without a specific reason is a bug waiting to surface. diff --git a/microsoft/knowledge/performance/use-addloadfields-in-report-layouts.md b/microsoft/knowledge/performance/use-addloadfields-in-report-layouts.md deleted file mode 100644 index 4331947..0000000 --- a/microsoft/knowledge/performance/use-addloadfields-in-report-layouts.md +++ /dev/null @@ -1,27 +0,0 @@ ---- -bc-version: [26..28] -domain: performance -keywords: [report, addloadfields, ondatapreitem, layout, partial-record] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Use AddLoadFields in report dataitems - -> **Seed article.** Converted from an existing performance-review prompt to bootstrap the BCQuality performance corpus. Domain stewards should expand, restructure, and refine as needed. - -## Description - -Reports iterate a dataitem's record automatically; the developer does not control the Find call directly. AddLoadFields, called in OnPreDataItem, tells the platform which fields the layout and the dataitem triggers will read. Without it the report streams every field of every row — for a ledger-entry dataitem on a production tenant, that is the dominant cost of the report. - -## Best Practice - -In each dataitem's OnPreDataItem trigger, call AddLoadFields for every field used by the layout, by the dataitem's triggers, and by any code that runs in the row-level event hooks. If the layout uses a FlowField, also ensure CalcFields is called and that the underlying key is loaded (see add-sift-keys-for-flowfields). - -See sample: `use-addloadfields-in-report-layouts.good.al`. - -## Anti Pattern - -Omitting AddLoadFields is the default for reports generated by the AL wizard. For a dataitem backed by a ledger-entry table, this silently turns the report into a full-column scan. - diff --git a/microsoft/knowledge/performance/use-calcsums-for-flowfield-totals.bad.al b/microsoft/knowledge/performance/use-calcsums-for-flowfield-totals.bad.al deleted file mode 100644 index efdbdb0..0000000 --- a/microsoft/knowledge/performance/use-calcsums-for-flowfield-totals.bad.al +++ /dev/null @@ -1,14 +0,0 @@ -codeunit 50115 "Perf Sample CalcSums Bad" -{ - procedure OutstandingForCustomer(CustomerNo: Code[20]) Total: Decimal - var - CustLedgerEntry: Record "Cust. Ledger Entry"; - begin - CustLedgerEntry.SetRange("Customer No.", CustomerNo); - CustLedgerEntry.SetRange(Open, true); - if CustLedgerEntry.FindSet() then - repeat - Total += CustLedgerEntry."Remaining Amt. (LCY)"; - until CustLedgerEntry.Next() = 0; - end; -} diff --git a/microsoft/knowledge/performance/use-calcsums-for-flowfield-totals.good.al b/microsoft/knowledge/performance/use-calcsums-for-flowfield-totals.good.al deleted file mode 100644 index f825b1f..0000000 --- a/microsoft/knowledge/performance/use-calcsums-for-flowfield-totals.good.al +++ /dev/null @@ -1,12 +0,0 @@ -codeunit 50114 "Perf Sample CalcSums Good" -{ - procedure OutstandingForCustomer(CustomerNo: Code[20]): Decimal - var - CustLedgerEntry: Record "Cust. Ledger Entry"; - begin - CustLedgerEntry.SetRange("Customer No.", CustomerNo); - CustLedgerEntry.SetRange(Open, true); - CustLedgerEntry.CalcSums("Remaining Amt. (LCY)"); - exit(CustLedgerEntry."Remaining Amt. (LCY)"); - end; -} diff --git a/microsoft/knowledge/performance/use-calcsums-for-flowfield-totals.md b/microsoft/knowledge/performance/use-calcsums-for-flowfield-totals.md deleted file mode 100644 index 19a2e2c..0000000 --- a/microsoft/knowledge/performance/use-calcsums-for-flowfield-totals.md +++ /dev/null @@ -1,29 +0,0 @@ ---- -bc-version: [26..28] -domain: performance -keywords: [calcsums, sift, sum, aggregate, totals] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Use CalcSums to aggregate filtered sets - -> **Seed article.** Converted from an existing performance-review prompt to bootstrap the BCQuality performance corpus. Domain stewards should expand, restructure, and refine as needed. - -## Description - -When the task is to compute a sum over a filtered set, CalcSums lets the platform push the aggregation down to SQL using SIFT indexes. Iterating rows in AL to accumulate a total transports every row's data to the runtime only to discard it after adding one field. On ledger-entry-scale tables this difference is dramatic. The same SIFT infrastructure backs Sum-style FlowFields; when the value you need is already declared as a FlowField, calling CalcSums on the underlying table with the correct filters produces the same aggregate. - -## Best Practice - -Set the required filters on the record, then call CalcSums on the field you want aggregated. Ensure the table has a key whose SumIndexFields includes the summed field and whose key prefix matches the filters (see add-sift-keys-for-flowfields). - -See sample: `use-calcsums-for-flowfield-totals.good.al`. - -## Anti Pattern - -Looping a filtered set with FindSet and adding a field to an accumulator on every iteration performs work in AL that SQL already knows how to do in one aggregate query. - -See sample: `use-calcsums-for-flowfield-totals.bad.al`. - diff --git a/microsoft/knowledge/performance/use-findset-readonly-by-default.bad.al b/microsoft/knowledge/performance/use-findset-readonly-by-default.bad.al deleted file mode 100644 index efd0e49..0000000 --- a/microsoft/knowledge/performance/use-findset-readonly-by-default.bad.al +++ /dev/null @@ -1,10 +0,0 @@ -codeunit 50109 "Perf Sample FindSetReadonly Bad" -{ - procedure SumInvoiceLines(var SalesLine: Record "Sales Line") Total: Decimal - begin - if SalesLine.FindSet(true) then - repeat - Total += SalesLine."Line Amount"; - until SalesLine.Next() = 0; - end; -} diff --git a/microsoft/knowledge/performance/use-findset-readonly-by-default.good.al b/microsoft/knowledge/performance/use-findset-readonly-by-default.good.al deleted file mode 100644 index 371c5f6..0000000 --- a/microsoft/knowledge/performance/use-findset-readonly-by-default.good.al +++ /dev/null @@ -1,10 +0,0 @@ -codeunit 50108 "Perf Sample FindSetReadonly Good" -{ - procedure SumInvoiceLines(var SalesLine: Record "Sales Line") Total: Decimal - begin - if SalesLine.FindSet() then - repeat - Total += SalesLine."Line Amount"; - until SalesLine.Next() = 0; - end; -} diff --git a/microsoft/knowledge/performance/use-findset-readonly-by-default.md b/microsoft/knowledge/performance/use-findset-readonly-by-default.md deleted file mode 100644 index 64cbe72..0000000 --- a/microsoft/knowledge/performance/use-findset-readonly-by-default.md +++ /dev/null @@ -1,29 +0,0 @@ ---- -bc-version: [26..28] -domain: performance -keywords: [findset, lock, locktable, readonly, update] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Use FindSet in read-only mode by default - -> **Seed article.** Converted from an existing performance-review prompt to bootstrap the BCQuality performance corpus. Domain stewards should expand, restructure, and refine as needed. - -## 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. - -## 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. - -See sample: `use-findset-readonly-by-default.good.al`. - -## Anti Pattern - -Writing FindSet(true) reflexively for every iteration forces the platform to take a LockTable on every call, even when the loop only reads values. The older two-parameter signature `FindSet(ForUpdate, UpdateKey)` is obsolete and must not be used. - -See sample: `use-findset-readonly-by-default.bad.al`. - diff --git a/microsoft/knowledge/performance/use-findset-with-next.bad.al b/microsoft/knowledge/performance/use-findset-with-next.bad.al deleted file mode 100644 index 8467ce1..0000000 --- a/microsoft/knowledge/performance/use-findset-with-next.bad.al +++ /dev/null @@ -1,10 +0,0 @@ -codeunit 50103 "Perf Sample FindSetWithNext Bad" -{ - procedure SumLineAmounts(var SalesLine: Record "Sales Line") Total: Decimal - begin - if SalesLine.FindFirst() then - repeat - Total += SalesLine."Line Amount"; - until SalesLine.Next() = 0; - end; -} diff --git a/microsoft/knowledge/performance/use-findset-with-next.good.al b/microsoft/knowledge/performance/use-findset-with-next.good.al deleted file mode 100644 index 412c943..0000000 --- a/microsoft/knowledge/performance/use-findset-with-next.good.al +++ /dev/null @@ -1,10 +0,0 @@ -codeunit 50102 "Perf Sample FindSetWithNext Good" -{ - procedure SumLineAmounts(var SalesLine: Record "Sales Line") Total: Decimal - begin - if SalesLine.FindSet() then - repeat - Total += SalesLine."Line Amount"; - until SalesLine.Next() = 0; - end; -} diff --git a/microsoft/knowledge/performance/use-findset-with-next.md b/microsoft/knowledge/performance/use-findset-with-next.md deleted file mode 100644 index f15912f..0000000 --- a/microsoft/knowledge/performance/use-findset-with-next.md +++ /dev/null @@ -1,29 +0,0 @@ ---- -bc-version: [26..28] -domain: performance -keywords: [findset, next, repeat, iteration, aa0181] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Use FindSet with Next for iteration - -> **Seed article.** Converted from an existing performance-review prompt to bootstrap the BCQuality performance corpus. Domain stewards should expand, restructure, and refine as needed. - -## Description - -When iterating over a filtered set of records with repeat-until, use FindSet together with Next. CodeCop rule AA0181 requires FindSet or Find to be paired with Next; using FindFirst or FindLast as the loop starter misrepresents intent and leads to rule AA0233. - -## Best Practice - -Call FindSet to start the iteration and Next to advance. Guard the loop with the standard `if FindSet() then ... until Next() = 0` idiom so callers can still handle the empty-set case. - -See sample: `use-findset-with-next.good.al`. - -## Anti Pattern - -Starting a repeat-until loop with FindFirst or FindLast reads only one row and then calls Next on an iterator that was not intended for full-set traversal. The platform pays extra work to fetch the single row and the loop silhouette is misleading to reviewers. - -See sample: `use-findset-with-next.bad.al`. - diff --git a/microsoft/knowledge/performance/prefer-get-for-primary-key-lookups.bad.al b/microsoft/knowledge/performance/use-get-instead-of-findfirst-on-full-primary-key.bad.al similarity index 52% rename from microsoft/knowledge/performance/prefer-get-for-primary-key-lookups.bad.al rename to microsoft/knowledge/performance/use-get-instead-of-findfirst-on-full-primary-key.bad.al index 408c2e9..34f4ec3 100644 --- a/microsoft/knowledge/performance/prefer-get-for-primary-key-lookups.bad.al +++ b/microsoft/knowledge/performance/use-get-instead-of-findfirst-on-full-primary-key.bad.al @@ -1,11 +1,11 @@ -codeunit 50131 "Perf Sample GetVsFind Bad" +codeunit 50211 "Perf Sample GetByPK Bad" { - procedure CustomerName(CustomerNo: Code[20]): Text[100] + procedure ShowName(CustomerNo: Code[20]) var Customer: Record Customer; begin Customer.SetRange("No.", CustomerNo); if Customer.FindFirst() then - exit(Customer.Name); + Message(Customer.Name); end; } diff --git a/microsoft/knowledge/performance/use-get-instead-of-findfirst-on-full-primary-key.good.al b/microsoft/knowledge/performance/use-get-instead-of-findfirst-on-full-primary-key.good.al new file mode 100644 index 0000000..d625150 --- /dev/null +++ b/microsoft/knowledge/performance/use-get-instead-of-findfirst-on-full-primary-key.good.al @@ -0,0 +1,10 @@ +codeunit 50210 "Perf Sample GetByPK Good" +{ + procedure ShowName(CustomerNo: Code[20]) + var + Customer: Record Customer; + begin + if Customer.Get(CustomerNo) then + Message(Customer.Name); + end; +} diff --git a/microsoft/knowledge/performance/use-get-instead-of-findfirst-on-full-primary-key.md b/microsoft/knowledge/performance/use-get-instead-of-findfirst-on-full-primary-key.md new file mode 100644 index 0000000..06c0383 --- /dev/null +++ b/microsoft/knowledge/performance/use-get-instead-of-findfirst-on-full-primary-key.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: performance +keywords: [get, findfirst, primary-key, setrange, lookup] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Use Get when the full primary key is known; FindFirst is the wrong tool + +## Description + +`Get(...)` is the direct primary-key lookup. `FindFirst()` walks an index — even when narrowed by `SetRange` on every primary-key field. The upstream review guidance treats `Customer.SetRange("No.", CustomerNo); if Customer.FindFirst() then ...` as a bad pattern and `if Customer.Get(CustomerNo) then ...` as the correction. The two reach the same record; only `Get` expresses the lookup as a primary-key seek. + +## Best Practice + +When all primary-key fields are available at the call site, call `Get` (or `GetBySystemId`) with them. Reserve `FindFirst` for cases where the filter is on something other than the full primary key — a unique secondary field, a partial composite key, a sort that the caller cares about. + +See sample: `use-get-instead-of-findfirst-on-full-primary-key.good.al`. + +## Anti Pattern + +Composing `SetRange` calls that exactly cover the primary key and then calling `FindFirst`. The result is correct but the call site reads as "search the table" rather than "look up by key", which obscures both the intent and the access pattern from later reviewers. + +See sample: `use-get-instead-of-findfirst-on-full-primary-key.bad.al`. diff --git a/microsoft/knowledge/performance/use-insert-false-when-skipping-triggers.good.al b/microsoft/knowledge/performance/use-insert-false-when-skipping-triggers.good.al deleted file mode 100644 index cc3377a..0000000 --- a/microsoft/knowledge/performance/use-insert-false-when-skipping-triggers.good.al +++ /dev/null @@ -1,12 +0,0 @@ -codeunit 50132 "Perf Sample InsertParam Good" -{ - procedure BulkLoadTempItems(var TempItem: Record Item temporary; Source: List of [Code[20]]) - var - ItemNo: Code[20]; - begin - foreach ItemNo in Source do begin - TempItem."No." := ItemNo; - TempItem.Insert(false); - end; - end; -} diff --git a/microsoft/knowledge/performance/use-insert-false-when-skipping-triggers.md b/microsoft/knowledge/performance/use-insert-false-when-skipping-triggers.md deleted file mode 100644 index 748c6a2..0000000 --- a/microsoft/knowledge/performance/use-insert-false-when-skipping-triggers.md +++ /dev/null @@ -1,27 +0,0 @@ ---- -bc-version: [26..28] -domain: performance -keywords: [insert, modify, delete, triggers, parameters] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Choose Insert, Modify, and Delete parameters deliberately - -> **Seed article.** Converted from an existing performance-review prompt to bootstrap the BCQuality performance corpus. Domain stewards should expand, restructure, and refine as needed. - -## Description - -Insert, Modify, and Delete accept a boolean that controls whether the table's OnInsert / OnModify / OnDelete trigger fires. Running the trigger for scratch or migrated data is often unnecessary work — side effects, posting rules, validations — for rows that were already validated upstream. Running the trigger when application logic depends on it is non-negotiable. - -## Best Practice - -Call Insert(true), Modify(true), or Delete(true) when the table's trigger logic is part of the operation's semantics. Call Insert(false), Modify(false), or Delete(false) when the operation is bulk data movement or temporary-table manipulation and the trigger would duplicate work or fire invalid side effects. - -See sample: `use-insert-false-when-skipping-triggers.good.al`. - -## Anti Pattern - -Blindly passing `true` everywhere pays for triggers on rows that do not need them. Blindly passing `false` silently skips validations that the table's author intended to be mandatory. - diff --git a/microsoft/knowledge/performance/use-isempty-for-existence-check.bad.al b/microsoft/knowledge/performance/use-isempty-for-existence-check.bad.al new file mode 100644 index 0000000..1ab47e7 --- /dev/null +++ b/microsoft/knowledge/performance/use-isempty-for-existence-check.bad.al @@ -0,0 +1,17 @@ +codeunit 50213 "Perf Sample IsEmpty Bad" +{ + procedure HasOpenSalesOrders(CustomerNo: Code[20]): Boolean + var + SalesHeader: Record "Sales Header"; + begin + SalesHeader.SetRange("Document Type", SalesHeader."Document Type"::Order); + SalesHeader.SetRange("Sell-to Customer No.", CustomerNo); + // Count materializes a count the caller does not need. + if SalesHeader.Count() > 0 then + exit(true); + // FindFirst materializes a row the caller throws away. + if SalesHeader.FindFirst() then + exit(true); + exit(false); + end; +} diff --git a/microsoft/knowledge/performance/use-isempty-for-existence-check.good.al b/microsoft/knowledge/performance/use-isempty-for-existence-check.good.al new file mode 100644 index 0000000..b1ea8d8 --- /dev/null +++ b/microsoft/knowledge/performance/use-isempty-for-existence-check.good.al @@ -0,0 +1,11 @@ +codeunit 50212 "Perf Sample IsEmpty Good" +{ + procedure HasOpenSalesOrders(CustomerNo: Code[20]): Boolean + var + SalesHeader: Record "Sales Header"; + begin + SalesHeader.SetRange("Document Type", SalesHeader."Document Type"::Order); + SalesHeader.SetRange("Sell-to Customer No.", CustomerNo); + exit(not SalesHeader.IsEmpty()); + end; +} diff --git a/microsoft/knowledge/performance/use-isempty-for-existence-check.md b/microsoft/knowledge/performance/use-isempty-for-existence-check.md new file mode 100644 index 0000000..ab574ec --- /dev/null +++ b/microsoft/knowledge/performance/use-isempty-for-existence-check.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: performance +keywords: [isempty, count, findfirst, existence-check, exists] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Use IsEmpty for existence checks, not Count() or FindFirst() + +## Description + +When the caller only needs to know whether any row matches a filter, `IsEmpty()` is the API designed for the question. Per the upstream guidance, "`IsEmpty()` is more efficient as it stops at first record found." `Count() > 0` materializes a count the caller does not need; `FindFirst()` materializes a row the caller does not need. Both do work that `IsEmpty` does not. + +## Best Practice + +Phrase existence checks as `if not Record.IsEmpty() then ...` (or `if Record.IsEmpty() then ...` for the negative). Apply filters via `SetRange`/`SetFilter` before the call so the existence check runs against the intended subset. Reserve `Count` for cases where the actual number matters and `FindFirst` for cases where the record fields are read. + +See sample: `use-isempty-for-existence-check.good.al`. + +## Anti Pattern + +`if Customer.Count() > 0 then ...` and `if Customer.FindFirst() then ...` (when the record is discarded) — both are flagged by the upstream guidance as the wrong tool. The first asks the database for the full count; the second asks for a row's fields. Both answers go unused. + +See sample: `use-isempty-for-existence-check.bad.al`. diff --git a/microsoft/knowledge/performance/use-isempty-for-existence-checks.bad.al b/microsoft/knowledge/performance/use-isempty-for-existence-checks.bad.al deleted file mode 100644 index 8674d63..0000000 --- a/microsoft/knowledge/performance/use-isempty-for-existence-checks.bad.al +++ /dev/null @@ -1,11 +0,0 @@ -codeunit 50121 "Perf Sample IsEmpty Bad" -{ - procedure HasOpenDocuments(CustomerNo: Code[20]): Boolean - var - SalesHeader: Record "Sales Header"; - begin - SalesHeader.SetRange("Sell-to Customer No.", CustomerNo); - SalesHeader.SetRange(Status, SalesHeader.Status::Open); - exit(SalesHeader.Count() > 0); - end; -} diff --git a/microsoft/knowledge/performance/use-isempty-for-existence-checks.good.al b/microsoft/knowledge/performance/use-isempty-for-existence-checks.good.al deleted file mode 100644 index 72cff56..0000000 --- a/microsoft/knowledge/performance/use-isempty-for-existence-checks.good.al +++ /dev/null @@ -1,11 +0,0 @@ -codeunit 50120 "Perf Sample IsEmpty Good" -{ - procedure HasOpenDocuments(CustomerNo: Code[20]): Boolean - var - SalesHeader: Record "Sales Header"; - begin - SalesHeader.SetRange("Sell-to Customer No.", CustomerNo); - SalesHeader.SetRange(Status, SalesHeader.Status::Open); - exit(not SalesHeader.IsEmpty()); - end; -} diff --git a/microsoft/knowledge/performance/use-isempty-for-existence-checks.md b/microsoft/knowledge/performance/use-isempty-for-existence-checks.md deleted file mode 100644 index 7199a74..0000000 --- a/microsoft/knowledge/performance/use-isempty-for-existence-checks.md +++ /dev/null @@ -1,29 +0,0 @@ ---- -bc-version: [26..28] -domain: performance -keywords: [isempty, count, findfirst, existence] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Use IsEmpty for existence checks - -> **Seed article.** Converted from an existing performance-review prompt to bootstrap the BCQuality performance corpus. Domain stewards should expand, restructure, and refine as needed. - -## Description - -IsEmpty is the cheapest way to answer whether at least one row matches the current filters. It short-circuits at the first match and never hydrates a record. Count() scans and counts the entire set; FindFirst fetches a full row just to be discarded. - -## Best Practice - -Use `if not Rec.IsEmpty() then ...` for existence checks. Reserve Count for cases where the exact number of rows is needed, and FindFirst for cases where you actually want the row's field values. - -See sample: `use-isempty-for-existence-checks.good.al`. - -## Anti Pattern - -`if Rec.Count() > 0` iterates the whole set just to answer a yes/no question. `if Rec.FindFirst() then` loads an entire row of data the caller never reads. - -See sample: `use-isempty-for-existence-checks.bad.al`. - diff --git a/microsoft/knowledge/performance/use-setloadfields-for-partial-records.bad.al b/microsoft/knowledge/performance/use-setloadfields-for-partial-records.bad.al index 7cf7fc9..c9b4ce2 100644 --- a/microsoft/knowledge/performance/use-setloadfields-for-partial-records.bad.al +++ b/microsoft/knowledge/performance/use-setloadfields-for-partial-records.bad.al @@ -1,14 +1,14 @@ -codeunit 50111 "Perf Sample SetLoadFields Bad" +codeunit 50219 "Perf Sample LoadFields Bad" { - procedure ExportItemNumbers(var Item: Record Item) + procedure ListUSCustomerNames() + var + Customer: Record Customer; begin - if Item.FindSet() then + // Loads every Customer column on every row, when only Name is read. + Customer.SetRange("Country/Region Code", 'US'); + if Customer.FindSet() then repeat - Export(Item."No.", Item.Description); - until Item.Next() = 0; - end; - - local procedure Export(ItemNo: Code[20]; Description: Text[100]) - begin + Message(Customer.Name); + until Customer.Next() = 0; end; } diff --git a/microsoft/knowledge/performance/use-setloadfields-for-partial-records.good.al b/microsoft/knowledge/performance/use-setloadfields-for-partial-records.good.al index 990e3ee..772023c 100644 --- a/microsoft/knowledge/performance/use-setloadfields-for-partial-records.good.al +++ b/microsoft/knowledge/performance/use-setloadfields-for-partial-records.good.al @@ -1,15 +1,23 @@ -codeunit 50110 "Perf Sample SetLoadFields Good" +codeunit 50218 "Perf Sample LoadFields Good" { - procedure ExportItemNumbers(var Item: Record Item) + procedure ListUSCustomerNames() + var + Customer: Record Customer; begin - Item.SetLoadFields("No.", Description); - if Item.FindSet() then + Customer.SetLoadFields(Name); + Customer.SetRange("Country/Region Code", 'US'); + if Customer.FindSet() then repeat - Export(Item."No.", Item.Description); - until Item.Next() = 0; + Message(Customer.Name); + until Customer.Next() = 0; end; - local procedure Export(ItemNo: Code[20]; Description: Text[100]) + procedure LookupSkuPolicy(LocationCode: Code[10]) Policy: Enum "SKU Creation Method" + var + Location: Record Location; begin + Location.SetLoadFields("SKU Creation Policy"); + if Location.Get(LocationCode) then + Policy := Location."SKU Creation Policy"; end; } diff --git a/microsoft/knowledge/performance/use-setloadfields-for-partial-records.md b/microsoft/knowledge/performance/use-setloadfields-for-partial-records.md index e112fc7..85d20b3 100644 --- a/microsoft/knowledge/performance/use-setloadfields-for-partial-records.md +++ b/microsoft/knowledge/performance/use-setloadfields-for-partial-records.md @@ -1,29 +1,26 @@ --- -bc-version: [26..28] +bc-version: [all] domain: performance -keywords: [setloadfields, partial-record, blob, bandwidth] +keywords: [setloadfields, partial-record, normal-field, flowfield, get, findset] technologies: [al] countries: [w1] application-area: [all] --- -# Use SetLoadFields for partial records - -> **Seed article.** Converted from an existing performance-review prompt to bootstrap the BCQuality performance corpus. Domain stewards should expand, restructure, and refine as needed. +# Use SetLoadFields to load only the fields the code reads ## Description -SetLoadFields instructs the platform to hydrate only the listed fields on a record variable. On wide tables, or tables with BLOB or media fields, the difference is substantial: a Sales Invoice Line has dozens of fields and loading all of them for every row of a large set is wasted bandwidth. Primary key fields, SystemId, and system audit fields are always loaded automatically. SetLoadFields works only with FieldClass = Normal; FlowFields and FlowFilters cannot be partial-loaded. +`SetLoadFields(...)` declares the subset of normal fields the next read should materialize, "reducing data read and transfer thereby improving performance significantly." Per the upstream guidance, "the gains scale with the amount of rows read, so for loops that read many rows `SetLoadFields` is even more important." Primary-key fields, `SystemId`, and system audit fields are loaded automatically, "and fields that are filtered on are also automatically included" — those do not need to appear in the list. `SetLoadFields` only affects `FieldClass = Normal`; it does not narrow FlowFields or FlowFilters. ## 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 during the operation, including fields used in filters, calculations, and downstream function calls. Omitting a field that is later accessed triggers a second round-trip. +Before a `Get`, `FindSet`, or `FindFirst` that the procedure follows by reading only a handful of the table's fields, call `SetLoadFields` listing exactly those fields. The pattern `SetLoadFields(...); if Record.Get(...) then ...` is the upstream-endorsed shape. Skip `SetLoadFields` when the table has few fields (under ten), when the code reads most of them (above 60 %), when the loop runs ten or fewer iterations, or when the table is exempt for other reasons (`singleton-setup-tables-need-no-access-optimization.md`, `temporary-tables-have-no-database-cost.md`). For report dataitems, use `AddLoadFields` in `OnPreDataItem` instead (see `addloadfields-in-report-onpredataitem.md`). See sample: `use-setloadfields-for-partial-records.good.al`. ## Anti Pattern -Iterating a large set and reading only two or three fields without SetLoadFields forces the platform to transport every column for every row, including BLOBs and unused text fields. +Loading a wide table and reading one field per row in a loop. The bytes transferred per row are dominated by the columns the procedure does not touch; the SQL query selects them anyway. The same applies to a single `Get` on a wide table — the platform reads the whole row when a single field would have sufficed. See sample: `use-setloadfields-for-partial-records.bad.al`. - diff --git a/microsoft/knowledge/performance/use-single-instance-codeunits-for-caching.good.al b/microsoft/knowledge/performance/use-single-instance-codeunits-for-caching.good.al deleted file mode 100644 index 036eccb..0000000 --- a/microsoft/knowledge/performance/use-single-instance-codeunits-for-caching.good.al +++ /dev/null @@ -1,17 +0,0 @@ -codeunit 50138 "Perf Sample SingleInstance Good" -{ - SingleInstance = true; - - var - Cached: Record "Sales & Receivables Setup"; - Loaded: Boolean; - - procedure GetSetup(): Record "Sales & Receivables Setup" - begin - if not Loaded then begin - Cached.Get(); - Loaded := true; - end; - exit(Cached); - end; -} diff --git a/microsoft/knowledge/performance/use-single-instance-codeunits-for-caching.md b/microsoft/knowledge/performance/use-single-instance-codeunits-for-caching.md deleted file mode 100644 index ca98596..0000000 --- a/microsoft/knowledge/performance/use-single-instance-codeunits-for-caching.md +++ /dev/null @@ -1,27 +0,0 @@ ---- -bc-version: [26..28] -domain: performance -keywords: [singleinstance, cache, codeunit, session] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Use SingleInstance codeunits for session caching - -> **Seed article.** Converted from an existing performance-review prompt to bootstrap the BCQuality performance corpus. Domain stewards should expand, restructure, and refine as needed. - -## Description - -A SingleInstance codeunit lives once per session. Variables on it survive across calls, which makes it the natural home for data that is expensive to compute, read often, and stable for the duration of the session — feature flags, configuration snapshots, setup records. Each cached value avoids a SQL read per subsequent call site. - -## Best Practice - -Store long-lived, read-often, rarely-changing data on a SingleInstance codeunit, populated lazily on first access. Keep the cached footprint small: a handful of booleans, a setup record, a few derived values. Be explicit about invalidation if the source can change during the session. - -See sample: `use-single-instance-codeunits-for-caching.good.al`. - -## Anti Pattern - -Reading the same setup record on every call from every caller, instead of caching it, repeats a SQL round-trip that has no business happening more than once per session. - diff --git a/microsoft/knowledge/performance/use-strsubstno-for-message-formatting.bad.al b/microsoft/knowledge/performance/use-strsubstno-for-message-formatting.bad.al deleted file mode 100644 index bcfa7d0..0000000 --- a/microsoft/knowledge/performance/use-strsubstno-for-message-formatting.bad.al +++ /dev/null @@ -1,7 +0,0 @@ -codeunit 50137 "Perf Sample StrSubstNo Bad" -{ - procedure CustomerGreeting(var Customer: Record Customer): Text - begin - exit('Hello, ' + Customer.Name + ' (' + Customer."No." + ')'); - end; -} diff --git a/microsoft/knowledge/performance/use-strsubstno-for-message-formatting.good.al b/microsoft/knowledge/performance/use-strsubstno-for-message-formatting.good.al deleted file mode 100644 index 87883e8..0000000 --- a/microsoft/knowledge/performance/use-strsubstno-for-message-formatting.good.al +++ /dev/null @@ -1,9 +0,0 @@ -codeunit 50136 "Perf Sample StrSubstNo Good" -{ - procedure CustomerGreeting(var Customer: Record Customer): Text - var - GreetingLbl: Label 'Hello, %1 (%2)'; - begin - exit(StrSubstNo(GreetingLbl, Customer.Name, Customer."No.")); - end; -} diff --git a/microsoft/knowledge/performance/use-strsubstno-for-message-formatting.md b/microsoft/knowledge/performance/use-strsubstno-for-message-formatting.md deleted file mode 100644 index 6e2b3f6..0000000 --- a/microsoft/knowledge/performance/use-strsubstno-for-message-formatting.md +++ /dev/null @@ -1,29 +0,0 @@ ---- -bc-version: [26..28] -domain: performance -keywords: [strsubstno, string, concatenation, format] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Use StrSubstNo for message formatting - -> **Seed article.** Converted from an existing performance-review prompt to bootstrap the BCQuality performance corpus. Domain stewards should expand, restructure, and refine as needed. - -## Description - -StrSubstNo formats values into a placeholder template in a single call. Manual concatenation with `+` produces a chain of intermediate strings, each allocated and discarded, and mixes formatting rules inconsistently across locales. The performance difference per call is small; repeated inside a tight loop it is noticeable. - -## Best Practice - -Declare the template as a Label (so it can be localized) and format with StrSubstNo. Pass values in the order the placeholders expect; StrSubstNo handles locale-sensitive conversions consistently. - -See sample: `use-strsubstno-for-message-formatting.good.al`. - -## Anti Pattern - -Building a user-facing string by concatenating record field values with string literals ignores locale rules and allocates more than necessary. - -See sample: `use-strsubstno-for-message-formatting.bad.al`. - diff --git a/microsoft/knowledge/performance/use-temporary-tables-for-intermediate-data.good.al b/microsoft/knowledge/performance/use-temporary-tables-for-intermediate-data.good.al deleted file mode 100644 index ecf550f..0000000 --- a/microsoft/knowledge/performance/use-temporary-tables-for-intermediate-data.good.al +++ /dev/null @@ -1,16 +0,0 @@ -codeunit 50124 "Perf Sample TempTable Good" -{ - procedure BuildAffectedItems(var TempItem: Record Item temporary) - var - SalesLine: Record "Sales Line"; - begin - TempItem.Reset(); - TempItem.DeleteAll(); - SalesLine.SetRange(Type, SalesLine.Type::Item); - if SalesLine.FindSet() then - repeat - TempItem."No." := SalesLine."No."; - if TempItem.Insert(false) then; - until SalesLine.Next() = 0; - end; -} diff --git a/microsoft/knowledge/performance/use-temporary-tables-for-intermediate-data.md b/microsoft/knowledge/performance/use-temporary-tables-for-intermediate-data.md deleted file mode 100644 index 3c12938..0000000 --- a/microsoft/knowledge/performance/use-temporary-tables-for-intermediate-data.md +++ /dev/null @@ -1,27 +0,0 @@ ---- -bc-version: [26..28] -domain: performance -keywords: [temporary-table, in-memory, intermediate, working-set] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Use temporary tables for intermediate data - -> **Seed article.** Converted from an existing performance-review prompt to bootstrap the BCQuality performance corpus. Domain stewards should expand, restructure, and refine as needed. - -## Description - -Temporary tables live in memory, not in SQL. They are the correct primary data structure for intermediate results, working sets, and lookup caches that do not need to outlive the current operation. Using a real persisted table for scratch data incurs database round-trips, transaction scope, and locking for data that has no business being persisted. - -## Best Practice - -Declare the record variable with `temporary` when the data is scratch. Populate it with Insert(false) to avoid firing triggers. Clear the table explicitly with DeleteAll when the variable's scope is long-lived (a SingleInstance codeunit or a reused session variable) and needs to be reset between uses. - -See sample: `use-temporary-tables-for-intermediate-data.good.al`. - -## Anti Pattern - -Writing intermediate results to a real table, processing them, and deleting them afterwards performs the full cost of INSERT and DELETE operations on data that never needed to be transactional. - diff --git a/microsoft/knowledge/performance/use-textbuilder-for-string-concatenation-in-loops.md b/microsoft/knowledge/performance/use-textbuilder-for-string-concatenation-in-loops.md new file mode 100644 index 0000000..f19636a --- /dev/null +++ b/microsoft/knowledge/performance/use-textbuilder-for-string-concatenation-in-loops.md @@ -0,0 +1,22 @@ +--- +bc-version: [all] +domain: performance +keywords: [textbuilder, string-concatenation, loop, append, immutable-text] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Use TextBuilder for many string concatenations, especially inside loops + +## Description + +AL `Text` is immutable: each `Result += Piece;` allocates a new buffer and copies the previous content into it. Inside a loop the work is quadratic in the number of pieces. `TextBuilder` is the AL primitive designed for the pattern — per the upstream guidance, "Use `TextBuilder` when concatenating many strings together (for example inside loops)." Its `Append` mutates a growable internal buffer; `ToText()` materializes the final string once at the end. + +## Best Practice + +When a procedure assembles a string from many fragments — joining row data into a CSV, accumulating a log buffer, formatting a multi-line message inside a loop — declare a `TextBuilder` local, call `Append` per fragment, and call `ToText()` after the loop. For a fixed number of small fragments, `StrSubstNo` remains the right tool; the rule targets the loop case. + +## Anti Pattern + +`if Customer.FindSet() then repeat Csv += Customer."No." + ',' + Customer.Name + '\n'; until Customer.Next() = 0;` — every iteration reallocates and copies the entire string built so far. On a few hundred customers the cost is invisible; on the production-scale table list (`production-scale-tables-warrant-extra-analysis.md`) it dominates the loop. diff --git a/microsoft/knowledge/performance/use-tryfunction-for-error-catching-not-rollback.bad.al b/microsoft/knowledge/performance/use-tryfunction-for-error-catching-not-rollback.bad.al new file mode 100644 index 0000000..648bdfd --- /dev/null +++ b/microsoft/knowledge/performance/use-tryfunction-for-error-catching-not-rollback.bad.al @@ -0,0 +1,17 @@ +codeunit 50156 "Perf Sample TryFunc Bad" +{ + procedure ApplyDiscountAttempt(var Customer: Record Customer) + begin + if not TryApplyDiscount(Customer) then + Message('Discount not applied'); + end; + + [TryFunction] + local procedure TryApplyDiscount(var Customer: Record Customer) + begin + Customer.Validate("Customer Price Group", 'VIP'); + Customer.Modify(true); + if Customer."Credit Limit (LCY)" <= 0 then + Error('Customer %1 not eligible', Customer."No."); + end; +} diff --git a/microsoft/knowledge/performance/use-tryfunction-for-error-catching-not-rollback.good.al b/microsoft/knowledge/performance/use-tryfunction-for-error-catching-not-rollback.good.al new file mode 100644 index 0000000..4e6f747 --- /dev/null +++ b/microsoft/knowledge/performance/use-tryfunction-for-error-catching-not-rollback.good.al @@ -0,0 +1,23 @@ +codeunit 50155 "Perf Sample TryFunc Good" +{ + procedure ParseAndProcess(Payload: Text) + var + ParsedValue: Decimal; + begin + ClearLastError(); + if not TryParseDecimal(Payload, ParsedValue) then begin + LogParseFailure(Payload, GetLastErrorText()); + exit; + end; + end; + + [TryFunction] + local procedure TryParseDecimal(Input: Text; var Result: Decimal) + begin + Evaluate(Result, Input); + end; + + local procedure LogParseFailure(Payload: Text; Reason: Text) + begin + end; +} diff --git a/microsoft/knowledge/performance/use-tryfunction-for-error-catching-not-rollback.md b/microsoft/knowledge/performance/use-tryfunction-for-error-catching-not-rollback.md new file mode 100644 index 0000000..bb015f6 --- /dev/null +++ b/microsoft/knowledge/performance/use-tryfunction-for-error-catching-not-rollback.md @@ -0,0 +1,28 @@ +--- +bc-version: [all] +domain: performance +keywords: [try-function, try-method, error-handling, rollback, atomic, exception, get-last-error, session-buffer] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Use [TryFunction] for error catching, Codeunit.Run for atomic rollback + +## Description + +`[TryFunction]` annotates a method so that errors raised inside it can be caught by the caller instead of propagating. Per the platform reference, "changes to the database that are made with a try method aren't rolled back" — the attribute catches the error; it does not unwind database state. This is the critical distinction from `Codeunit.Run`, which does roll back on error (see `codeunit-run-as-atomic-sub-operation.md`). A try function also only catches when its return value is used: "If the return variable for a call to a function, which is attributed with [TryFunction] isn't used, then the call isn't considered a try function call." `DoTry();` propagates errors normally; only `ok := DoTry();` or `if DoTry() then ...` catches. The return type is forced to Boolean; user-defined return types are not allowed, and the value isn't accessible inside the try method itself. On Business Central on-premises, writes inside a try method are blocked by default and raise a runtime error unless `DisableWriteInsideTryFunctions` is set to `false` on the server — SaaS has no such restriction. + +## Best Practice + +Reach for `[TryFunction]` when you want to catch a failure without unwinding the transaction — HTTP calls whose non-2xx responses should surface a user-friendly message, .NET interop whose exceptions you want to translate, validation or parsing routines whose errors you intend to log and continue past. Always capture the return: `if MyTry() then ... else HandleFailure(GetLastErrorText());`. When the work is transactional — writes that must either fully apply or fully revert — use `Codeunit.Run` instead. The two primitives solve different problems: one catches errors, the other bounds a rollback scope. + +Use `[TryFunction]` sparingly. Each caught error writes to the session-wide `GetLastErrorText` and `GetLastErrorCallStack` buffers, and every subsequent catch overwrites the earlier state — a helper that reads `GetLastErrorText` later may see a different error than the one it intended to inspect. Prefer explicit checks (non-throwing predicates, guard conditions, upfront validation) for operations with predictable failure modes; reserve `[TryFunction]` for genuinely unpredictable failures such as network calls, third-party interop, or evaluation of user-supplied expressions. When you do catch, read `GetLastErrorText` immediately after the failed call, and call `ClearLastError` before the call if an earlier catch in the same scope could have left state behind — per the platform reference, "If you call the GetLastErrorText method immediately after you call the ClearLastError method, then an empty string is returned." + +See sample: `use-tryfunction-for-error-catching-not-rollback.good.al`. + +## Anti Pattern + +Wrapping database writes in `[TryFunction]` expecting the writes to roll back when the method errors. They do not: the writes that succeeded before the error remain, the caller receives `false`, and the corrupted-state bug surfaces in production. A related anti-pattern is calling a try function without capturing the return (`DoTry();`), which silently strips the error-catching behavior and lets the error propagate — the code looks defensive but behaves identically to an unwrapped call. A third is defensive sprinkling: wrapping every operation that *could* theoretically error in `[TryFunction]` on the theory that catching is always safer than propagating. Each extra catch pollutes the shared error buffer and makes the diagnostic signal harder to find when something real does fail. + +See sample: `use-tryfunction-for-error-catching-not-rollback.bad.al`. diff --git a/microsoft/knowledge/privacy/avoid-strsubstno-prebuild-before-error.bad.al b/microsoft/knowledge/privacy/avoid-strsubstno-prebuild-before-error.bad.al new file mode 100644 index 0000000..08f1702 --- /dev/null +++ b/microsoft/knowledge/privacy/avoid-strsubstno-prebuild-before-error.bad.al @@ -0,0 +1,11 @@ +codeunit 50207 "Privacy Sample StrSubstNo Bad" +{ + procedure ReportFailure(var Customer: Record Customer) + var + ErrorMsg: Text; + begin + ErrorMsg := StrSubstNo('Customer %1 (%2) at %3 has invalid data', + Customer.Name, Customer."E-Mail", Customer.Address); + Error(ErrorMsg); + end; +} diff --git a/microsoft/knowledge/privacy/avoid-strsubstno-prebuild-before-error.good.al b/microsoft/knowledge/privacy/avoid-strsubstno-prebuild-before-error.good.al new file mode 100644 index 0000000..6886e29 --- /dev/null +++ b/microsoft/knowledge/privacy/avoid-strsubstno-prebuild-before-error.good.al @@ -0,0 +1,9 @@ +codeunit 50206 "Privacy Sample StrSubstNo Good" +{ + procedure ReportFailure(var Customer: Record Customer) + var + CustomerInvalidErr: Label 'Customer %1 has invalid data (email: %2).', Comment = '%1 = Customer No., %2 = E-Mail'; + begin + Error(CustomerInvalidErr, Customer."No.", Customer."E-Mail"); + end; +} diff --git a/microsoft/knowledge/privacy/avoid-strsubstno-prebuild-before-error.md b/microsoft/knowledge/privacy/avoid-strsubstno-prebuild-before-error.md new file mode 100644 index 0000000..7d4c1e7 --- /dev/null +++ b/microsoft/knowledge/privacy/avoid-strsubstno-prebuild-before-error.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: privacy +keywords: [strsubstno, error, telemetry, pii, prebuild, text-variable] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Do not pre-build an error string with `StrSubstNo` before calling `Error()` + +## Description + +`StrSubstNo` returns a plain `Text` value with the substitutions already performed. When that result is then passed to `Error()`, the platform sees a single plain-text parameter with no field references left to inspect, so it cannot apply `DataClassification` to anything inside it. Whatever PII the `StrSubstNo` call interpolated — customer name, e-mail, address, error text — is logged verbatim to telemetry. This is the canonical way to accidentally leak customer data through error telemetry, and it is the only `Error()` shape that needs to be flagged. + +## Best Practice + +Call `Error()` directly with the format string and the substitution parameters. The platform classifies each parameter individually and handles telemetry correctly even when the parameters are PII fields (see `error-direct-substitution-safe-for-telemetry.md`). If the message text needs to be a `Label`, pass the `Label` and the parameters to `Error()` — do not pre-render via `StrSubstNo`. + +See sample: `avoid-strsubstno-prebuild-before-error.good.al`. + +## Anti Pattern + +Assigning `StrSubstNo('Customer %1 (%2) ...', Customer.Name, Customer."E-Mail")` to a `Text` variable and then calling `Error(ErrorMsg)`. The platform has nothing to classify by the time `Error` runs — the PII is baked into the string and goes straight to telemetry. Detection signal for a reviewer: any `Text` variable assigned from `StrSubstNo` and later passed as the *only* parameter to `Error()`. + +See sample: `avoid-strsubstno-prebuild-before-error.bad.al`. diff --git a/microsoft/knowledge/privacy/data-classification-is-table-field-property.md b/microsoft/knowledge/privacy/data-classification-is-table-field-property.md new file mode 100644 index 0000000..acfc0e7 --- /dev/null +++ b/microsoft/knowledge/privacy/data-classification-is-table-field-property.md @@ -0,0 +1,22 @@ +--- +bc-version: [all] +domain: privacy +keywords: [data-classification, page-field, table-field, api-page, card-page, list-page] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# DataClassification is a table-field property, not a page-field property + +## Description + +`DataClassification` is defined on table fields. Pages — including `Card`, `List`, `API`, and `ListPart` — do not own a classification; they simply expose fields whose classification is inherited from the underlying table. A page-level `DataClassification` property does not exist, so neither a missing nor a "wrong" classification can be reported against a page. When the underlying table field is misclassified, the fix is on the table definition, not on every page that surfaces the field. + +## Best Practice + +When reviewing a page that exposes a field believed to be under-classified, follow the field back to its source table and inspect (or correct) the `DataClassification` there. A single corrected table field propagates to every page, report and API that uses it. + +## Anti Pattern + +Flagging a page (or trying to add a `DataClassification` property to a page field) because the page displays personal data. Pages display data that authenticated, permissioned users are already entitled to see; the classification belongs on the table field that stores the data, not on the UI that renders it. diff --git a/microsoft/knowledge/privacy/data-classification-required-on-pii-fields.bad.al b/microsoft/knowledge/privacy/data-classification-required-on-pii-fields.bad.al new file mode 100644 index 0000000..72b2174 --- /dev/null +++ b/microsoft/knowledge/privacy/data-classification-required-on-pii-fields.bad.al @@ -0,0 +1,11 @@ +tableextension 50201 "Customer Contact Ext Bad" extends Customer +{ + fields + { + field(50201; "Secondary Email"; Text[80]) + { + DataClassification = SystemMetadata; + Caption = 'Secondary Email'; + } + } +} diff --git a/microsoft/knowledge/privacy/data-classification-required-on-pii-fields.good.al b/microsoft/knowledge/privacy/data-classification-required-on-pii-fields.good.al new file mode 100644 index 0000000..aef9301 --- /dev/null +++ b/microsoft/knowledge/privacy/data-classification-required-on-pii-fields.good.al @@ -0,0 +1,11 @@ +tableextension 50200 "Customer Contact Ext" extends Customer +{ + fields + { + field(50200; "Secondary Email"; Text[80]) + { + DataClassification = CustomerContent; + Caption = 'Secondary Email'; + } + } +} diff --git a/microsoft/knowledge/privacy/data-classification-required-on-pii-fields.md b/microsoft/knowledge/privacy/data-classification-required-on-pii-fields.md new file mode 100644 index 0000000..808e103 --- /dev/null +++ b/microsoft/knowledge/privacy/data-classification-required-on-pii-fields.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: privacy +keywords: [data-classification, pii, gdpr, customer-content, table-field, under-classified] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# DataClassification is required on table fields containing sensitive data + +## Description + +`DataClassification` is the AL property that tells the platform what kind of data a table field stores so that telemetry, GDPR data-subject requests, and the platform's audit surfaces can treat it correctly. It is required on any field that holds personal or customer data. The default value `SystemMetadata` means "no user or customer data" — applying it to a field that actually holds PII (an email address, a customer name, an employee code) is an under-classification and a privacy bug, even though the code still compiles. + +## Best Practice + +Set `DataClassification` to the value that matches the data the field actually stores. A `Customer."E-Mail"`-style field is `CustomerContent` (data belonging to the tenant's customers); a personal identifier such as an employee number or user ID is `EndUserIdentifiableInformation` or `EndUserPseudonymousIdentifiers` depending on whether it is directly identifying. Choose the classification at field definition time — fixing it later is a schema change. + +See sample: `data-classification-required-on-pii-fields.good.al`. + +## Anti Pattern + +Declaring a field that stores PII with `DataClassification = SystemMetadata` to silence the compiler warning. The field compiles but the platform now treats customer data as system metadata in telemetry, GDPR exports and admin reports. + +See sample: `data-classification-required-on-pii-fields.bad.al`. diff --git a/microsoft/knowledge/privacy/error-direct-substitution-safe-for-telemetry.good.al b/microsoft/knowledge/privacy/error-direct-substitution-safe-for-telemetry.good.al new file mode 100644 index 0000000..1b8c886 --- /dev/null +++ b/microsoft/knowledge/privacy/error-direct-substitution-safe-for-telemetry.good.al @@ -0,0 +1,10 @@ +codeunit 50205 "Privacy Sample Direct Error" +{ + procedure ValidateCustomer(var Customer: Record Customer) + var + InvalidEmailErr: Label 'Customer %1 has an invalid e-mail address: %2.', Comment = '%1 = Customer No., %2 = E-Mail'; + begin + if not Customer."E-Mail".Contains('@') then + Error(InvalidEmailErr, Customer."No.", Customer."E-Mail"); + end; +} diff --git a/microsoft/knowledge/privacy/error-direct-substitution-safe-for-telemetry.md b/microsoft/knowledge/privacy/error-direct-substitution-safe-for-telemetry.md new file mode 100644 index 0000000..8653ecf --- /dev/null +++ b/microsoft/knowledge/privacy/error-direct-substitution-safe-for-telemetry.md @@ -0,0 +1,24 @@ +--- +bc-version: [all] +domain: privacy +keywords: [error, strsubstno, direct-substitution, telemetry, classification, label] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# `Error()` with direct substitution parameters is always safe for telemetry + +## Description + +When `Error()` is called with a format string and direct substitution parameters (`%1`, `%2`, …), the BC platform intercepts the call, inspects each parameter individually, and applies the `DataClassification` of the source field — stripping or masking sensitive data before writing the message to telemetry. This is true regardless of whether a parameter is a record field reference, a local variable, a function return value, or any other expression. Patterns such as `Error('Invalid email: %1', Customer."E-Mail")` are therefore safe even when the parameter is PII: the platform sees `Customer."E-Mail"` as a `CustomerContent` field reference and handles it correctly. + +## Best Practice + +Pass values to `Error()` as direct substitution parameters — either inline or via a `Label` with `Comment = '%1 = …'` placeholders. Let the platform do the per-parameter classification. This works equally well for record fields, local text variables, and document IDs. + +See sample: `error-direct-substitution-safe-for-telemetry.good.al`. + +## Anti Pattern + +Treating any `Error()` call that mentions PII as a leak. A review skill that flags `Error('Invalid email: %1', EmailAddress)` is wrong; the platform handles that pattern correctly. The only `Error()` shape that genuinely leaks PII to telemetry is the pre-built `StrSubstNo` form covered in `avoid-strsubstno-prebuild-before-error.md`. diff --git a/microsoft/knowledge/privacy/error-vs-message-telemetry-logging.md b/microsoft/knowledge/privacy/error-vs-message-telemetry-logging.md new file mode 100644 index 0000000..f7372b7 --- /dev/null +++ b/microsoft/knowledge/privacy/error-vs-message-telemetry-logging.md @@ -0,0 +1,22 @@ +--- +bc-version: [all] +domain: privacy +keywords: [error, message, confirm, notification, telemetry, logging, ui-dialog] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Only `Error()` is logged to telemetry — `Message`, `Confirm`, `Notification` are not + +## Description + +The privacy concern with dialog APIs is not what the signed-in user sees on the screen — it is what the platform writes to telemetry. The BC platform automatically captures `Error()` invocations in the telemetry stream; it does not capture `Message()`, `Confirm()` or `Notification` calls. That asymmetry is the reason privacy review focuses on `Error()` text and ignores the other dialog APIs: a `Message` that shows a customer's email to the signed-in user reveals nothing they were not already entitled to see, while an `Error` carrying the same email leaks it to a separate, longer-lived telemetry destination. + +## Best Practice + +Treat `Error()` as a telemetry surface, not just a UI surface — review the message text and parameters with the same scrutiny you apply to `Session.LogMessage`. Treat `Message()`, `Confirm()`, and `Notification` as pure UI: showing business data the user is permissioned for is normal functionality. + +## Anti Pattern + +Flagging `Message`/`Confirm`/`Notification` calls for "showing PII" — they are not logged to telemetry, and the user already has permission to the underlying data. The inverse anti-pattern is treating `Error()` as harmless because the user sees only a dialog: the message is also written verbatim to telemetry. diff --git a/microsoft/knowledge/privacy/featuretelemetry-customdimensions-no-pii.bad.al b/microsoft/knowledge/privacy/featuretelemetry-customdimensions-no-pii.bad.al new file mode 100644 index 0000000..8225269 --- /dev/null +++ b/microsoft/knowledge/privacy/featuretelemetry-customdimensions-no-pii.bad.al @@ -0,0 +1,12 @@ +codeunit 50215 "Privacy Sample FeatureTelemetry Bad" +{ + procedure LogDocumentReleased(ExpenseHeader: Record "Sales Header"; var User: Record User) + var + FeatureTelemetry: Codeunit "Feature Telemetry"; + CustomDimensions: Dictionary of [Text, Text]; + begin + CustomDimensions.Add('EmployeeNo', ExpenseHeader."Sell-to Customer No."); + CustomDimensions.Add('UserName', User."Full Name"); + FeatureTelemetry.LogUsage('0000EA1', 'Expense Agent', 'Document Released', CustomDimensions); + end; +} diff --git a/microsoft/knowledge/privacy/featuretelemetry-customdimensions-no-pii.good.al b/microsoft/knowledge/privacy/featuretelemetry-customdimensions-no-pii.good.al new file mode 100644 index 0000000..6172f9d --- /dev/null +++ b/microsoft/knowledge/privacy/featuretelemetry-customdimensions-no-pii.good.al @@ -0,0 +1,10 @@ +codeunit 50214 "Privacy Sample FeatureTelemetry Good" +{ + procedure LogUptake() + var + FeatureTelemetry: Codeunit "Feature Telemetry"; + begin + FeatureTelemetry.LogUptake('0000EA2', 'Expense Agent', + Enum::"Feature Uptake Status"::"Set up"); + end; +} diff --git a/microsoft/knowledge/privacy/featuretelemetry-customdimensions-no-pii.md b/microsoft/knowledge/privacy/featuretelemetry-customdimensions-no-pii.md new file mode 100644 index 0000000..6d8bfb5 --- /dev/null +++ b/microsoft/knowledge/privacy/featuretelemetry-customdimensions-no-pii.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: privacy +keywords: [feature-telemetry, customdimensions, logusage, loguptake, logerror, pii, euii, eupi] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# `FeatureTelemetry` `CustomDimensions` follow the same privacy rules as `Session.LogMessage` + +## Description + +`Codeunit "Feature Telemetry"` is the second telemetry surface in AL. Its methods — `LogUsage()`, `LogUptake()` and `LogError()` — each accept a `CustomDimensions` dictionary parameter whose contents are sent to telemetry as-is. The platform does not classify per-dimension values for you, so any customer or employee data placed into the dictionary is logged verbatim. The privacy rules that apply to `Session.LogMessage` message text apply to every value in `CustomDimensions`: no customer or employee names, email addresses, phone numbers (`CustomerContent`/EUII); no employee codes, user IDs or user security IDs (EUPI); no user-provided content (addresses, descriptions, notes); no `GetLastErrorText()` output. + +## Best Practice + +Pass only non-personal context through `CustomDimensions` — feature names, status enums, counts, error codes, durations. For uptake or usage signals that do not need per-call context, prefer the parameterless overload of `LogUptake`/`LogUsage` over a `CustomDimensions` dictionary that risks accreting PII over time. + +See sample: `featuretelemetry-customdimensions-no-pii.good.al`. + +## Anti Pattern + +`CustomDimensions.Add('EmployeeNo', ExpenseHeader."Employee No.")` followed by `FeatureTelemetry.LogUsage(...)` — the employee number is a pseudonymous user identifier (EUPI) and is now in telemetry. Same pattern with `'UserName'`, `'CustomerEmail'`, `'AttachmentName'` etc. + +See sample: `featuretelemetry-customdimensions-no-pii.bad.al`. diff --git a/microsoft/knowledge/privacy/flowfield-flowfilter-classification-systemmetadata.good.al b/microsoft/knowledge/privacy/flowfield-flowfilter-classification-systemmetadata.good.al new file mode 100644 index 0000000..c31ced8 --- /dev/null +++ b/microsoft/knowledge/privacy/flowfield-flowfilter-classification-systemmetadata.good.al @@ -0,0 +1,18 @@ +tableextension 50203 "Customer Order Stats" extends Customer +{ + fields + { + field(50203; "Open Order Count"; Integer) + { + FieldClass = FlowField; + CalcFormula = count("Sales Header" where("Sell-to Customer No." = field("No."))); + Caption = 'Open Order Count'; + } + + field(50204; "Date Filter"; Date) + { + FieldClass = FlowFilter; + Caption = 'Date Filter'; + } + } +} diff --git a/microsoft/knowledge/privacy/flowfield-flowfilter-classification-systemmetadata.md b/microsoft/knowledge/privacy/flowfield-flowfilter-classification-systemmetadata.md new file mode 100644 index 0000000..d3a1b7b --- /dev/null +++ b/microsoft/knowledge/privacy/flowfield-flowfilter-classification-systemmetadata.md @@ -0,0 +1,24 @@ +--- +bc-version: [all] +domain: privacy +keywords: [flowfield, flowfilter, data-classification, systemmetadata, calculated] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# FlowFields and FlowFilters are classified `SystemMetadata` automatically + +## Description + +`FlowField` and `FlowFilter` are not stored fields — a FlowField is computed from a CalcFormula at read time and a FlowFilter is a transient filter scoped to the record variable. Because nothing is ever written to the database for these fields, the platform automatically classifies them as `DataClassification = SystemMetadata` and AL does not require — or expect — the developer to set `DataClassification` on them. A FlowField that surfaces PII (e.g., a sum or lookup over a `CustomerContent` table) is still `SystemMetadata` at the FlowField level; the privacy classification lives on the underlying stored field that the CalcFormula references. + +## Best Practice + +Do not declare `DataClassification` on `FieldClass = FlowField` or `FieldClass = FlowFilter` fields — the inherited `SystemMetadata` is correct and the property is redundant. If a FlowField exposes sensitive data, ensure the underlying source field has the right `DataClassification`; that is where the platform reads classification from for GDPR and telemetry purposes. + +See sample: `flowfield-flowfilter-classification-systemmetadata.good.al`. + +## Anti Pattern + +Flagging a FlowField for "missing `DataClassification`" or trying to override it to `CustomerContent` because the formula references customer data. The platform's automatic `SystemMetadata` value is the documented, intentional behavior for non-stored fields; overriding it adds nothing and misrepresents the field as if it were stored. diff --git a/microsoft/knowledge/privacy/getlasterrortext-customer-content-in-errors.bad.al b/microsoft/knowledge/privacy/getlasterrortext-customer-content-in-errors.bad.al new file mode 100644 index 0000000..b943031 --- /dev/null +++ b/microsoft/knowledge/privacy/getlasterrortext-customer-content-in-errors.bad.al @@ -0,0 +1,17 @@ +codeunit 50209 "Privacy Sample GetLastError Bad" +{ + procedure AddAttachment() + var + ErrorMsg: Text; + begin + if not TryAddAttachment() then begin + ErrorMsg := StrSubstNo('Attachment failed: %1', GetLastErrorText(true)); + Error(ErrorMsg); + end; + end; + + [TryFunction] + local procedure TryAddAttachment() + begin + end; +} diff --git a/microsoft/knowledge/privacy/getlasterrortext-customer-content-in-errors.good.al b/microsoft/knowledge/privacy/getlasterrortext-customer-content-in-errors.good.al new file mode 100644 index 0000000..4b07537 --- /dev/null +++ b/microsoft/knowledge/privacy/getlasterrortext-customer-content-in-errors.good.al @@ -0,0 +1,16 @@ +codeunit 50208 "Privacy Sample GetLastError Good" +{ + procedure AddAttachmentSafely() + var + AttachmentFailedErr: Label 'Failed to add email attachment. Please try again.'; + begin + if not TryAddAttachment() then + Error(AttachmentFailedErr); + end; + + [TryFunction] + local procedure TryAddAttachment() + begin + // ... attachment logic that may fail with a customer-data-bearing error ... + end; +} diff --git a/microsoft/knowledge/privacy/getlasterrortext-customer-content-in-errors.md b/microsoft/knowledge/privacy/getlasterrortext-customer-content-in-errors.md new file mode 100644 index 0000000..769a3f5 --- /dev/null +++ b/microsoft/knowledge/privacy/getlasterrortext-customer-content-in-errors.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: privacy +keywords: [getlasterrortext, error, strsubstno, telemetry, customer-data, attachment] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Treat `GetLastErrorText()` as potential customer content + +## Description + +`GetLastErrorText()` returns the text of the last error that occurred in the context where it is called. That text routinely contains customer content — field values that triggered the validation, record keys, customer names, file names from upload failures, and similar fragments lifted from the failing operation. Re-emitting it through `StrSubstNo` into `Error()` bakes that customer data into a single plain-text parameter that the platform can no longer classify, so it is logged verbatim to telemetry (the same problem as any other `StrSubstNo`-pre-built error — see `avoid-strsubstno-prebuild-before-error.md`). + +## Best Practice + +When the goal is to surface a recoverable failure to the user, raise a generic message that does not embed `GetLastErrorText()` content, and log technical detail separately via `Session.LogMessage` with the correct `DataClassification`. If you must propagate the inner error verbatim, re-raise it as a direct parameter of `Error()` (e.g., `Error('%1', GetLastErrorText())`) rather than concatenating with `StrSubstNo` so the platform can apply its own handling. + +See sample: `getlasterrortext-customer-content-in-errors.good.al`. + +## Anti Pattern + +`ErrorMsg := StrSubstNo('Attachment failed: %1', GetLastErrorText(true)); Error(ErrorMsg);` — the inner error text may carry filenames or record values, and `StrSubstNo` strips the platform's ability to filter them before they hit telemetry. + +See sample: `getlasterrortext-customer-content-in-errors.bad.al`. diff --git a/microsoft/knowledge/privacy/in-memory-data-not-a-privacy-concern.md b/microsoft/knowledge/privacy/in-memory-data-not-a-privacy-concern.md new file mode 100644 index 0000000..38fb6f2 --- /dev/null +++ b/microsoft/knowledge/privacy/in-memory-data-not-a-privacy-concern.md @@ -0,0 +1,22 @@ +--- +bc-version: [all] +domain: privacy +keywords: [in-memory, dictionary, list, temporary-table, variable, memory-dump] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# In-memory variables, dictionaries, lists and temporary tables are not a privacy concern + +## Description + +AL runs in a managed server environment. Local variables, `Dictionary`, `List`, temporary `Record` variables, and other in-process data structures exist only for the duration of the request or session and are released by the runtime when it ends — they are not persisted, not visible across sessions, and not exposed outside the server process. Memory dumps are not a realistic threat vector against Business Central's hosted architecture, so holding business data (emails, names, addresses, document content) in these structures while processing a request is normal and expected. + +## Best Practice + +Use whatever in-memory shape (`Dictionary`, `List`, temporary tables, plain variables) the algorithm needs. The privacy review applies to *persistent* surfaces — table fields, telemetry, outgoing HTTP — not to per-request memory. + +## Anti Pattern + +Flagging a `Dictionary of [Text, Text]` populated with customer emails, or a temporary `Record Customer` holding rows mid-processing, as a privacy leak. These structures are scoped to the request and do not leave the server's memory. diff --git a/microsoft/knowledge/privacy/migration-destination-classification.md b/microsoft/knowledge/privacy/migration-destination-classification.md new file mode 100644 index 0000000..afb8e8d --- /dev/null +++ b/microsoft/knowledge/privacy/migration-destination-classification.md @@ -0,0 +1,22 @@ +--- +bc-version: [all] +domain: privacy +keywords: [data-migration, hybridsl, hybridgp, hybridbc, destination-classification, ssn, tin] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# In data migration code, classify the destination — not the migration itself + +## Description + +Migration codeunits such as `HybridSL`, `HybridGP`, and `HybridBC` exist to copy sensitive data — TINs, Federal IDs, social security numbers, financial records — from a source system into Business Central. The fact that PII flows through these codeunits is the entire point of their existence, not a defect. The privacy concern is whether the destination field where the data lands carries the correct `DataClassification`. If it does, the migration is doing its job; if it doesn't, the right fix is on the destination table field, never on the migration code that writes to it. + +## Best Practice + +When reviewing a migration codeunit, trace each `Dest."" := Source.""` assignment to the destination field's `DataClassification`. Confirm that fields receiving PII (SSNs, Federal IDs, customer names, addresses) are classified `EndUserIdentifiableInformation` or `CustomerContent` as appropriate — and not left as `SystemMetadata` or `ToBeClassified`. + +## Anti Pattern + +Flagging the migration code itself for "processing sensitive data" or recommending that it filter, hash, or skip PII fields — these tables exist to migrate that data. The actionable finding is always on the destination field's classification, not on the migration's assignment statement. diff --git a/microsoft/knowledge/privacy/no-pii-in-telemetry-message-string.bad.al b/microsoft/knowledge/privacy/no-pii-in-telemetry-message-string.bad.al new file mode 100644 index 0000000..06970e2 --- /dev/null +++ b/microsoft/knowledge/privacy/no-pii-in-telemetry-message-string.bad.al @@ -0,0 +1,21 @@ +codeunit 50213 "Privacy Sample Telemetry Bad" +{ + procedure LogCustomerProcessed(var Customer: Record Customer) + begin + Session.LogMessage('0000', StrSubstNo('Processed %1', Customer.Name), Verbosity::Normal, + DataClassification::SystemMetadata, TelemetryScope::All, + 'Category', 'Privacy'); + end; + + procedure LogFileError(FileName: Text) + begin + Session.LogMessage('0001', StrSubstNo('Error processing file %1', FileName), Verbosity::Error, + DataClassification::SystemMetadata, TelemetryScope::All); + end; + + procedure LogEmployeeUpdate(EmployeeCode: Code[20]) + begin + Session.LogMessage('0002', StrSubstNo('Employee %1 updated record', EmployeeCode), Verbosity::Normal, + DataClassification::SystemMetadata, TelemetryScope::All); + end; +} diff --git a/microsoft/knowledge/privacy/no-pii-in-telemetry-message-string.good.al b/microsoft/knowledge/privacy/no-pii-in-telemetry-message-string.good.al new file mode 100644 index 0000000..96a553a --- /dev/null +++ b/microsoft/knowledge/privacy/no-pii-in-telemetry-message-string.good.al @@ -0,0 +1,15 @@ +codeunit 50212 "Privacy Sample Telemetry Good" +{ + procedure LogCustomerProcessed(var Customer: Record Customer) + begin + Session.LogMessage('0000', 'Customer record processed', Verbosity::Normal, + DataClassification::SystemMetadata, TelemetryScope::All, + 'Category', 'Privacy'); + end; + + procedure LogFileError() + begin + Session.LogMessage('0001', 'Error processing uploaded file', Verbosity::Error, + DataClassification::SystemMetadata, TelemetryScope::All); + end; +} diff --git a/microsoft/knowledge/privacy/no-pii-in-telemetry-message-string.md b/microsoft/knowledge/privacy/no-pii-in-telemetry-message-string.md new file mode 100644 index 0000000..ba95fbe --- /dev/null +++ b/microsoft/knowledge/privacy/no-pii-in-telemetry-message-string.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: privacy +keywords: [telemetry, session-logmessage, strsubstno, pii, customer-data, employee-code, filename] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Do not embed customer data in the telemetry message text + +## Description + +`Session.LogMessage`'s message argument is a plain `Text`. Unlike `Error()`, the platform does not inspect this string field-by-field — whatever is in the text is what telemetry receives. So a call that builds the message via `StrSubstNo` from customer-bearing fields ships those values to telemetry verbatim, regardless of the `DataClassification` argument on the same call. Flagged content includes customer names, email addresses, phone numbers, addresses, employee codes or IDs, attachment filenames, user-provided text that may carry PII, and dumps of `Record` content. + +## Best Practice + +Keep the telemetry message a static, non-personal string ("Customer record processed", "Error processing uploaded file"). When structured context is genuinely needed, attach it through custom dimensions, where individual values can be reviewed and classified at the dimension level rather than baked into a free-text message. + +See sample: `no-pii-in-telemetry-message-string.good.al`. + +## Anti Pattern + +`Session.LogMessage('0000', StrSubstNo('Processed %1', Customer.Name), ...)` — the customer name is in telemetry the moment the line runs. Detection signal: a `StrSubstNo` whose result is the second argument of `Session.LogMessage`. The same shape with `FileName`, `EmployeeCode`, or any record field is the same problem. + +See sample: `no-pii-in-telemetry-message-string.bad.al`. diff --git a/microsoft/knowledge/privacy/page-display-is-not-a-privacy-concern.md b/microsoft/knowledge/privacy/page-display-is-not-a-privacy-concern.md new file mode 100644 index 0000000..6e8d62d --- /dev/null +++ b/microsoft/knowledge/privacy/page-display-is-not-a-privacy-concern.md @@ -0,0 +1,22 @@ +--- +bc-version: [all] +domain: privacy +keywords: [page, card, list, api, listpart, permission-system, display, ui-dialog] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Displaying fields on a page (or in a UI dialog) is not a privacy concern + +## Description + +Every page in Business Central — `Card`, `List`, `API`, `ListPart`, request pages — renders data to an authenticated user who has been granted permission to see it. The BC permission system, not the page definition, controls who sees what; once a user is permissioned to a table, displaying any field of that table is normal business functionality. The same logic extends to `Message`, `Notification` and `Confirm` dialogs: the signed-in user already has access to the data the dialog is showing them. Privacy review for pages and dialogs is therefore the wrong layer — the actionable findings live on the underlying data (table-field classification, telemetry message text, outbound HTTP consent), not on the UI. + +## Best Practice + +When asked "is it OK to show this email/name/employee code on this page?", the answer is yes — provided the user has permission to the underlying record. Drive privacy concerns to the data layer (classification, telemetry, external transfer) rather than the UI layer. + +## Anti Pattern + +Flagging an API page, list, card, or notification for surfacing customer-bearing fields (`E-Mail`, `Name`, `Phone No.`, audit fields, `User ID`). The permission system governs visibility; the page does not. diff --git a/microsoft/knowledge/privacy/privacy-notice-consent-for-external-data-transfer.bad.al b/microsoft/knowledge/privacy/privacy-notice-consent-for-external-data-transfer.bad.al new file mode 100644 index 0000000..6308674 --- /dev/null +++ b/microsoft/knowledge/privacy/privacy-notice-consent-for-external-data-transfer.bad.al @@ -0,0 +1,13 @@ +codeunit 50217 "Privacy Sample Consent Bad" +{ + procedure SendDataToExternalService(Customer: Record Customer) + var + HttpClient: HttpClient; + Content: HttpContent; + Response: HttpResponseMessage; + begin + Content.WriteFrom(StrSubstNo('{"email":"%1","name":"%2"}', + Customer."E-Mail", Customer.Name)); + HttpClient.Post('https://api.externalservice.com/sync', Content, Response); + end; +} diff --git a/microsoft/knowledge/privacy/privacy-notice-consent-for-external-data-transfer.good.al b/microsoft/knowledge/privacy/privacy-notice-consent-for-external-data-transfer.good.al new file mode 100644 index 0000000..0ac939c --- /dev/null +++ b/microsoft/knowledge/privacy/privacy-notice-consent-for-external-data-transfer.good.al @@ -0,0 +1,22 @@ +codeunit 50216 "Privacy Sample Consent Good" +{ + procedure SendDataToExternalService(Customer: Record Customer) + var + PrivacyNotice: Codeunit "Privacy Notice"; + PrivacyNoticeRegistrations: Codeunit "Privacy Notice Registrations"; + HttpClient: HttpClient; + Content: HttpContent; + Response: HttpResponseMessage; + PrivacyConsentRequiredErr: Label 'Privacy notice consent is required for this integration.'; + begin + if PrivacyNotice.GetPrivacyNoticeApprovalState( + PrivacyNoticeRegistrations.GetExchangePrivacyNoticeId()) + <> "Privacy Notice Approval State"::Agreed + then + Error(PrivacyConsentRequiredErr); + + Content.WriteFrom(StrSubstNo('{"email":"%1","name":"%2"}', + Customer."E-Mail", Customer.Name)); + HttpClient.Post('https://api.externalservice.com/sync', Content, Response); + end; +} diff --git a/microsoft/knowledge/privacy/privacy-notice-consent-for-external-data-transfer.md b/microsoft/knowledge/privacy/privacy-notice-consent-for-external-data-transfer.md new file mode 100644 index 0000000..a064792 --- /dev/null +++ b/microsoft/knowledge/privacy/privacy-notice-consent-for-external-data-transfer.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: privacy +keywords: [privacy-notice, consent, http-client, outgoing-request, external-service, getprivacynoticeapprovalstate] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Outgoing requests to external services require a Privacy Notice consent check + +## Description + +Business Central ships a built-in Privacy Notice framework that the admin uses to grant or withhold per-integration consent for sending data to external services. The relevant API surface is `Codeunit "Privacy Notice"` (consent checks via `GetPrivacyNoticeApprovalState()`), `Codeunit "Privacy Notice Registrations"` (well-known notice IDs for integrations such as Exchange, OneDrive, Teams), and the `Enum "Privacy Notice Approval State"` with values `Agreed`, `Disagreed`, and `Not Set`. The admin UI is the **Privacy Notices Status** page. The compliance concern in code review is therefore not that personal data is included in an outgoing HTTP body — that is normal business functionality — but that the code path issuing the request contains no `PrivacyNotice.GetPrivacyNoticeApprovalState(...)` check. + +## Best Practice + +Before issuing an outgoing HTTP request to an external service, verify `PrivacyNotice.GetPrivacyNoticeApprovalState() = "Privacy Notice Approval State"::Agreed`. The check does not have to live next to the `HttpClient.Post` call — it can sit anywhere upstream in the same code path (for example in the page's `OnOpenPage`, in a wizard step, or in a setup action) as long as no execution path reaches the request without passing through it. + +See sample: `privacy-notice-consent-for-external-data-transfer.good.al`. + +## Anti Pattern + +A `procedure SendDataToExternalService(...)` that posts customer data to an external endpoint with no `PrivacyNotice.GetPrivacyNoticeApprovalState` anywhere upstream. The same anti-pattern applies in reverse: removing an existing privacy-notice check from code that still issues the external call. + +See sample: `privacy-notice-consent-for-external-data-transfer.bad.al`. diff --git a/microsoft/knowledge/privacy/register-integration-in-privacy-notice-registrations.good.al b/microsoft/knowledge/privacy/register-integration-in-privacy-notice-registrations.good.al new file mode 100644 index 0000000..d8a20f5 --- /dev/null +++ b/microsoft/knowledge/privacy/register-integration-in-privacy-notice-registrations.good.al @@ -0,0 +1,11 @@ +codeunit 50218 "Privacy Sample Register Integration" +{ + [EventSubscriber(ObjectType::Codeunit, Codeunit::"Privacy Notice Registrations", 'OnRegisterPrivacyNotices', '', false, false)] + local procedure OnRegisterPrivacyNotices(var TempPrivacyNotice: Record "Privacy Notice" temporary) + var + PrivacyNotice: Codeunit "Privacy Notice"; + begin + PrivacyNotice.CreatePrivacyNoticeForIntegration( + 'My External Sync', 'External Customer Sync Service'); + end; +} diff --git a/microsoft/knowledge/privacy/register-integration-in-privacy-notice-registrations.md b/microsoft/knowledge/privacy/register-integration-in-privacy-notice-registrations.md new file mode 100644 index 0000000..40779e9 --- /dev/null +++ b/microsoft/knowledge/privacy/register-integration-in-privacy-notice-registrations.md @@ -0,0 +1,24 @@ +--- +bc-version: [all] +domain: privacy +keywords: [privacy-notice-registrations, integration, register, exchange, onedrive, teams, notice-id] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Register every new external integration with `Privacy Notice Registrations` + +## Description + +`Codeunit "Privacy Notice Registrations"` is the registry of integrations whose consent state the platform tracks. Built-in integrations such as Exchange, OneDrive and Teams already have notice IDs exposed via accessor methods on this codeunit (`GetExchangePrivacyNoticeId`, etc.); a new integration introduced by an extension must add itself to the registry so that the admin can grant or withhold consent on the **Privacy Notices Status** page. Without registration, there is nothing for `Codeunit "Privacy Notice"` to return an approval state for — the call cannot meaningfully gate the outbound request. + +## Best Practice + +When introducing a new outbound integration: pick a stable notice ID, register it via `Privacy Notice Registrations`, and then gate every outbound call with `PrivacyNotice.GetPrivacyNoticeApprovalState()` as described in `privacy-notice-consent-for-external-data-transfer.md`. + +See sample: `register-integration-in-privacy-notice-registrations.good.al`. + +## Anti Pattern + +Shipping a new outbound integration without registering it. Even if the code calls `GetPrivacyNoticeApprovalState`, the admin has no surface to express consent — the integration is effectively unmanaged from a privacy-notice standpoint. 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..6820704 --- /dev/null +++ b/microsoft/knowledge/privacy/resolve-tobeclassified-before-release.md @@ -0,0 +1,22 @@ +--- +bc-version: [all] +domain: privacy +keywords: [tobeclassified, data-classification, release, appsource, development] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Resolve every `ToBeClassified` before release + +## Description + +`DataClassification = ToBeClassified` is the sentinel value the AL compiler accepts while a developer has not yet decided what a new field actually stores. It exists for the development phase only and must be resolved to a real classification (`CustomerContent`, `EndUserIdentifiableInformation`, `EndUserPseudonymousIdentifiers`, `AccountData`, `OrganizationIdentifiableInformation` or `SystemMetadata`) before the code ships. A released field left at `ToBeClassified` tells the platform "we have not classified this data" — which means GDPR data-subject requests, telemetry and audit reports cannot reason about it. + +## Best Practice + +Treat `ToBeClassified` as a TODO marker that fails release readiness. Sweep new table objects and table extensions for it before submitting a build for publication. If the right classification is genuinely unclear, decide between `CustomerContent` and `EndUserIdentifiableInformation` from the data's content, not from convenience. + +## Anti Pattern + +Leaving `ToBeClassified` in a shipped extension. Reviewers who treat the value as "I'll figure it out later" ship a field whose privacy posture is undefined for every customer that installs the app. diff --git a/microsoft/knowledge/privacy/session-logmessage-requires-dataclassification.bad.al b/microsoft/knowledge/privacy/session-logmessage-requires-dataclassification.bad.al new file mode 100644 index 0000000..e895d7c --- /dev/null +++ b/microsoft/knowledge/privacy/session-logmessage-requires-dataclassification.bad.al @@ -0,0 +1,7 @@ +codeunit 50211 "Privacy Sample LogMessage Bad" +{ + procedure LogCompleted() + begin + Session.LogMessage('0003', 'Operation completed', Verbosity::Normal); + end; +} diff --git a/microsoft/knowledge/privacy/session-logmessage-requires-dataclassification.good.al b/microsoft/knowledge/privacy/session-logmessage-requires-dataclassification.good.al new file mode 100644 index 0000000..d3353ec --- /dev/null +++ b/microsoft/knowledge/privacy/session-logmessage-requires-dataclassification.good.al @@ -0,0 +1,8 @@ +codeunit 50210 "Privacy Sample LogMessage Good" +{ + procedure LogCompleted() + begin + Session.LogMessage('0003', 'Operation completed', Verbosity::Normal, + DataClassification::SystemMetadata, TelemetryScope::ExtensionPublisher); + end; +} diff --git a/microsoft/knowledge/privacy/session-logmessage-requires-dataclassification.md b/microsoft/knowledge/privacy/session-logmessage-requires-dataclassification.md new file mode 100644 index 0000000..67381f7 --- /dev/null +++ b/microsoft/knowledge/privacy/session-logmessage-requires-dataclassification.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: privacy +keywords: [session-logmessage, telemetry, data-classification, verbosity, telemetry-scope] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Every `Session.LogMessage` call must specify `DataClassification` + +## Description + +`Session.LogMessage` writes a record to the telemetry pipeline. The platform requires the call to carry an explicit `DataClassification` argument so that the entry can be routed and retained correctly downstream — telemetry consumers, GDPR exports, and Application Insights dashboards all rely on it. The compiler accepts overloads without the parameter (the two-argument and three-argument shapes that omit it), but for any telemetry that ships to customers, the `DataClassification`-bearing overload is the correct one. + +## Best Practice + +Use the overload that takes `Verbosity`, `DataClassification`, and `TelemetryScope`. For payload-free operational telemetry that does not embed customer data, `DataClassification::SystemMetadata` is the right value. Choose `TelemetryScope::ExtensionPublisher` for telemetry meant for the publishing partner only; `TelemetryScope::All` also forwards to the customer's tenant telemetry. + +See sample: `session-logmessage-requires-dataclassification.good.al`. + +## Anti Pattern + +Calling `Session.LogMessage('0003', 'Operation completed', Verbosity::Normal)` — the overload omits `DataClassification` and leaves the platform without the information needed to classify the entry. Detection signal: a `Session.LogMessage` call whose argument list ends at `Verbosity`. + +See sample: `session-logmessage-requires-dataclassification.bad.al`. diff --git a/microsoft/knowledge/privacy/table-level-data-classification-cascades.good.al b/microsoft/knowledge/privacy/table-level-data-classification-cascades.good.al new file mode 100644 index 0000000..e1e5808 --- /dev/null +++ b/microsoft/knowledge/privacy/table-level-data-classification-cascades.good.al @@ -0,0 +1,16 @@ +table 50202 "System Configuration Log" +{ + DataClassification = SystemMetadata; + + fields + { + field(1; "Entry No."; Integer) { } + field(2; "Changed By"; Code[50]) { } + field(3; "Change Description"; Text[250]) { } + } + + keys + { + key(PK; "Entry No.") { Clustered = true; } + } +} diff --git a/microsoft/knowledge/privacy/table-level-data-classification-cascades.md b/microsoft/knowledge/privacy/table-level-data-classification-cascades.md new file mode 100644 index 0000000..bf457e9 --- /dev/null +++ b/microsoft/knowledge/privacy/table-level-data-classification-cascades.md @@ -0,0 +1,24 @@ +--- +bc-version: [all] +domain: privacy +keywords: [data-classification, table-level, inheritance, override, cascading] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Table-level DataClassification cascades to every field unless overridden + +## Description + +`DataClassification` may be set at the table level. When it is, every field in the table inherits that classification and individual fields do not need their own `DataClassification` property. The cascade is the platform's intended way of classifying tables whose fields are homogeneous — for example, a system configuration log whose every column is `SystemMetadata`. A field only needs its own classification when its content genuinely differs from the table's default and the inherited value would be wrong. + +## Best Practice + +Set `DataClassification` once at the table level whenever every field in the table shares the same classification. Omit field-level `DataClassification` properties in that case. Override only on the specific fields whose data class differs from the table's — for example, a `SystemMetadata` audit table that nonetheless captures a `CustomerContent` value somewhere. + +See sample: `table-level-data-classification-cascades.good.al`. + +## Anti Pattern + +Flagging individual fields for "missing `DataClassification`" when the table declares one — the inheritance is the correct, intentional pattern. The mirror anti-pattern is repeating the same `DataClassification` on every field of a table that already declares it at the table level; the property is redundant and adds nothing the platform did not already know. diff --git a/microsoft/knowledge/security/al-has-no-built-in-htmlencode.bad.al b/microsoft/knowledge/security/al-has-no-built-in-htmlencode.bad.al new file mode 100644 index 0000000..ab698a1 --- /dev/null +++ b/microsoft/knowledge/security/al-has-no-built-in-htmlencode.bad.al @@ -0,0 +1,7 @@ +codeunit 50227 "Sec Sample HtmlEncode Bad" +{ + procedure BuildWelcomeHtml(UserName: Text): Text + begin + exit('
Welcome ' + UserName + '!
'); + end; +} diff --git a/microsoft/knowledge/security/al-has-no-built-in-htmlencode.good.al b/microsoft/knowledge/security/al-has-no-built-in-htmlencode.good.al new file mode 100644 index 0000000..6da1911 --- /dev/null +++ b/microsoft/knowledge/security/al-has-no-built-in-htmlencode.good.al @@ -0,0 +1,19 @@ +codeunit 50226 "Sec Sample HtmlEncode Good" +{ + procedure BuildWelcomeHtml(UserName: Text): Text + var + SafeName: Text; + begin + SafeName := EncodeHtml(UserName); + exit('
Welcome ' + SafeName + '!
'); + end; + + local procedure EncodeHtml(Value: Text): Text + begin + Value := Value.Replace('&', '&'); + Value := Value.Replace('<', '<'); + Value := Value.Replace('>', '>'); + Value := Value.Replace('"', '"'); + exit(Value); + end; +} diff --git a/microsoft/knowledge/security/al-has-no-built-in-htmlencode.md b/microsoft/knowledge/security/al-has-no-built-in-htmlencode.md new file mode 100644 index 0000000..7441712 --- /dev/null +++ b/microsoft/knowledge/security/al-has-no-built-in-htmlencode.md @@ -0,0 +1,22 @@ +--- +bc-version: [all] +domain: security +keywords: [html, xss, encoding, htmlencode, injection, email] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# AL has no built-in HtmlEncode — encode HTML output by hand or avoid it + +## Description + +AL does not ship a built-in `HtmlEncode` (or equivalent) function. Code that builds an HTML fragment — an email body, a report header, a chart label rendered as HTML — by concatenating record-field values into a string is therefore unencoded by default, and any `<`, `>`, `&`, or `"` in the user content is interpreted as markup by the receiving renderer. The result is cross-site scripting in the recipient's mail client, browser, or report viewer. The absence of a built-in encoder is non-obvious to anyone used to platforms where `HtmlEncode` is a one-liner. + +## Best Practice + +Replace the four characters by hand before concatenating user content into HTML: `&` → `&` first, then `<` → `<`, `>` → `>`, `"` → `"`. Centralize the substitution in one helper so every HTML producer in the extension uses the same encoder. Better still, do not build raw HTML at all — use a structured format (JSON for an API payload, a report layout for a printed document) and let the renderer do the encoding. See sample: `al-has-no-built-in-htmlencode.good.al`. + +## Anti Pattern + +`HtmlContent := '
Welcome ' + UserName + '!
'` — any record-field value or user input concatenated directly into an HTML string. Reviewers should flag any string concatenation whose right-hand operand is a field, a parameter, or any non-literal value, and whose surrounding context contains HTML tags (`<`, ` **Seed article.** Converted from an existing security-review prompt to bootstrap the BCQuality security corpus. Domain stewards should expand, restructure, and refine as needed. - -## Description - -Errors surfaced to end users are routinely forwarded to support systems, captured in bug reports, and exported to telemetry. Server names, database names, usernames, connection strings, file paths, and stack excerpts in an end-user error message leak infrastructure detail to untrusted consumers and help an attacker map the environment. - -## Best Practice - -Raise end-user errors using localized Labels that describe the condition without naming infrastructure. Emit the actual detail (exception text, endpoint, correlation id) through the application's internal logging channel, where audience and retention are controlled. - -See sample: `avoid-sensitive-data-in-error-messages.good.al`. - -## Anti Pattern - -Error('Failed to connect to Server=PROD-SQL01;Database=NAV;User=admin: %1', Ex.Message); — every support ticket now carries the server name, database name, and service account. - -See sample: `avoid-sensitive-data-in-error-messages.bad.al`. - diff --git a/microsoft/knowledge/security/commitbehavior-attribute-scopes-explicit-commits.bad.al b/microsoft/knowledge/security/commitbehavior-attribute-scopes-explicit-commits.bad.al new file mode 100644 index 0000000..c3b796b --- /dev/null +++ b/microsoft/knowledge/security/commitbehavior-attribute-scopes-explicit-commits.bad.al @@ -0,0 +1,26 @@ +codeunit 50151 "Sec Sample CommitBeh Bad" +{ + [IntegrationEvent(true, false)] + procedure OnBeforeApplyingDiscount(var Customer: Record Customer) + begin + end; + + procedure ApplyDiscount(var Customer: Record Customer) + begin + Customer."Customer Price Group" := 'VIP'; + Customer.Modify(true); + OnBeforeApplyingDiscount(Customer); + if Customer."Credit Limit (LCY)" <= 0 then + Error('Customer %1 not eligible', Customer."No."); + Commit(); + end; +} + +codeunit 50152 "Sec Sample CommitBeh Bad Sub" +{ + [EventSubscriber(ObjectType::Codeunit, Codeunit::"Sec Sample CommitBeh Bad", 'OnBeforeApplyingDiscount', '', true, true)] + local procedure NotifyOther(var Customer: Record Customer) + begin + Commit(); + end; +} diff --git a/microsoft/knowledge/security/commitbehavior-attribute-scopes-explicit-commits.good.al b/microsoft/knowledge/security/commitbehavior-attribute-scopes-explicit-commits.good.al new file mode 100644 index 0000000..0204d12 --- /dev/null +++ b/microsoft/knowledge/security/commitbehavior-attribute-scopes-explicit-commits.good.al @@ -0,0 +1,27 @@ +codeunit 50149 "Sec Sample CommitBeh Good" +{ + [CommitBehavior(CommitBehavior::Ignore)] + [IntegrationEvent(true, false)] + procedure OnBeforeApplyingDiscount(var Customer: Record Customer) + begin + end; + + procedure ApplyDiscount(var Customer: Record Customer) + begin + Customer."Customer Price Group" := 'VIP'; + Customer.Modify(true); + OnBeforeApplyingDiscount(Customer); + if Customer."Credit Limit (LCY)" <= 0 then + Error('Customer %1 not eligible', Customer."No."); + Commit(); + end; +} + +codeunit 50150 "Sec Sample CommitBeh Good Sub" +{ + [EventSubscriber(ObjectType::Codeunit, Codeunit::"Sec Sample CommitBeh Good", 'OnBeforeApplyingDiscount', '', true, true)] + local procedure NotifyOther(var Customer: Record Customer) + begin + Commit(); + end; +} diff --git a/microsoft/knowledge/security/commitbehavior-attribute-scopes-explicit-commits.md b/microsoft/knowledge/security/commitbehavior-attribute-scopes-explicit-commits.md new file mode 100644 index 0000000..b7a4ca7 --- /dev/null +++ b/microsoft/knowledge/security/commitbehavior-attribute-scopes-explicit-commits.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: security +keywords: [commit-behavior, attribute, integration-event, subscriber, commit, atomic] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Use [CommitBehavior] to protect an atomic operation from third-party commits + +## Description + +`[CommitBehavior(CommitBehavior::Ignore)]` and `[CommitBehavior(CommitBehavior::Error)]` are method-level attributes that restrict what an explicit `Commit()` does inside the annotated method's scope: `Ignore` silently discards the call; `Error` raises a runtime error. The behavior only lasts for that method's activation — it reverts on method exit whether the method succeeded or errored. The attribute only tightens, never loosens: a parent method running under `Error` overrides any attempt to declare `Ignore` on a nested method. The primary use case is protecting an atomic publisher method — typically an `IntegrationEvent` — from `Commit()` calls in subscribers written by third parties: "you can protect your code from commits happening in event subscriber code; typically written by a third party." The attribute applies to explicit commits only; it does not affect the implicit commit performed by `Codeunit.Run` (see `codeunit-run-requires-prior-commit-inside-transaction.md`). It combines with `[TryFunction]` — a single method may carry both attributes, and each governs its own dimension: `[CommitBehavior]` the commit policy, `[TryFunction]` the error-propagation policy (see `use-tryfunction-for-error-catching-not-rollback.md`). + +## Best Practice + +Annotate publisher methods whose transactional guarantees must survive extension code. The attribute is a selective guard, not a convention: most `IntegrationEvent` publishers do not need it. Events that fire from a standalone query, events fired after the publisher has already committed, informational hooks, and notification-style events are unaffected by subscriber commits. Reach for the attribute only when the publisher has uncommitted writes at the moment of firing and a premature inner commit would persist inconsistent state. Prefer `Ignore` over `Error` when the intent is "silently nullify" — an `Error` from an extension's commit would surface as a subscriber-authored dialog rather than a publisher-defined failure mode. Pair the attribute with the actual atomic-boundary logic in the publisher (validate, then `Commit` on success); a subscriber's suppressed commit remains a no-op regardless of how the publisher completes. + +See sample: `commitbehavior-attribute-scopes-explicit-commits.good.al`. + +## Anti Pattern + +Publishing an `IntegrationEvent` from inside an atomic operation without `[CommitBehavior(CommitBehavior::Ignore)]`. A third-party subscriber that calls `Commit()` — intentionally or by accident — persists the publisher's partial state, defeating any rollback the publisher would have performed on a later validation failure. Another anti-pattern is placing the attribute on a wrapper method and calling a nested `Codeunit.Run` that writes, expecting the attribute to suppress the implicit commit: it does not. The mirror-image anti-pattern is applying the attribute reflexively to every `IntegrationEvent` regardless of context — events that fire outside an atomic sequence gain nothing from the protection, and adding it everywhere clutters the review surface and masks the publishers that genuinely need it. + +See sample: `commitbehavior-attribute-scopes-explicit-commits.bad.al`. diff --git a/microsoft/knowledge/security/compose-secrets-with-secretstrsubstno.bad.al b/microsoft/knowledge/security/compose-secrets-with-secretstrsubstno.bad.al deleted file mode 100644 index 0365988..0000000 --- a/microsoft/knowledge/security/compose-secrets-with-secretstrsubstno.bad.al +++ /dev/null @@ -1,9 +0,0 @@ -codeunit 50215 "Sec Sample SecretCompose Bad" -{ - procedure BuildAuthHeader(Token: Text) AuthHeader: Text - begin - // Token is Text, so the combined value is plaintext. - // The whole shape should have used SecretText + SecretStrSubstNo. - AuthHeader := StrSubstNo('Bearer %1', Token); - end; -} diff --git a/microsoft/knowledge/security/compose-secrets-with-secretstrsubstno.good.al b/microsoft/knowledge/security/compose-secrets-with-secretstrsubstno.good.al deleted file mode 100644 index 91117b4..0000000 --- a/microsoft/knowledge/security/compose-secrets-with-secretstrsubstno.good.al +++ /dev/null @@ -1,7 +0,0 @@ -codeunit 50214 "Sec Sample SecretCompose Good" -{ - procedure BuildAuthHeader(Token: SecretText) AuthHeader: SecretText - begin - AuthHeader := SecretStrSubstNo('Bearer %1', Token); - end; -} diff --git a/microsoft/knowledge/security/compose-secrets-with-secretstrsubstno.md b/microsoft/knowledge/security/compose-secrets-with-secretstrsubstno.md deleted file mode 100644 index 0d88c52..0000000 --- a/microsoft/knowledge/security/compose-secrets-with-secretstrsubstno.md +++ /dev/null @@ -1,29 +0,0 @@ ---- -bc-version: [26..28] -domain: security -keywords: [secretstrsubstno, secrettext, composition] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Compose secrets with SecretStrSubstNo - -> **Seed article.** Converted from an existing security-review prompt to bootstrap the BCQuality security corpus. Domain stewards should expand, restructure, and refine as needed. - -## Description - -SecretStrSubstNo is the SecretText analogue of StrSubstNo. The template is a regular string literal; substitution arguments may be SecretText; the return value is SecretText. Intermediate results of the composition are never materialized as plaintext. - -## Best Practice - -Format SecretText templates with SecretStrSubstNo. This is the correct primitive for building authorization headers, secret URIs, and any other formatted string that embeds a SecretText. Provide the static parts of the template as a regular string literal; only the substitutions carry the secret value. - -See sample: `compose-secrets-with-secretstrsubstno.good.al`. - -## Anti Pattern - -Using StrSubstNo (or plain string concatenation) on a plain-Text token to build an authorization header. The result is a Text containing the secret in plaintext, visible in the debugger, inspectable in snapshot debug sessions, and captured by any logging the caller does not control. SecretText should have been used end-to-end. - -See sample: `compose-secrets-with-secretstrsubstno.bad.al`. - diff --git a/microsoft/knowledge/security/do-not-expose-sensitive-data-in-event-publishers.bad.al b/microsoft/knowledge/security/do-not-expose-sensitive-data-in-event-publishers.bad.al deleted file mode 100644 index c111d18..0000000 --- a/microsoft/knowledge/security/do-not-expose-sensitive-data-in-event-publishers.bad.al +++ /dev/null @@ -1,19 +0,0 @@ -codeunit 50229 "Sec Sample EventPublisher Bad" -{ - [IntegrationEvent(false, false)] - local procedure OnBeforeExportCustomer(CustomerNo: Code[20]; ExportCredentials: SecretText; var AllowExport: Boolean) - begin - end; - - procedure ExportCustomer(CustomerNo: Code[20]; Credentials: SecretText) - var - AllowExport: Boolean; - begin - // Any subscriber on the tenant receives the credentials and - // can flip AllowExport := true to bypass the publisher's check. - OnBeforeExportCustomer(CustomerNo, Credentials, AllowExport); - if not AllowExport then - exit; - // ... perform export - end; -} diff --git a/microsoft/knowledge/security/do-not-expose-sensitive-data-in-event-publishers.good.al b/microsoft/knowledge/security/do-not-expose-sensitive-data-in-event-publishers.good.al deleted file mode 100644 index 8d7962c..0000000 --- a/microsoft/knowledge/security/do-not-expose-sensitive-data-in-event-publishers.good.al +++ /dev/null @@ -1,23 +0,0 @@ -codeunit 50228 "Sec Sample EventPublisher Good" -{ - [IntegrationEvent(false, false)] - local procedure OnBeforeExportCustomer(CustomerNo: Code[20]) - begin - end; - - procedure ExportCustomer(CustomerNo: Code[20]) - begin - if not CallerIsAuthorizedToExport(CustomerNo) then - Error('You are not authorized to export this customer.'); - - OnBeforeExportCustomer(CustomerNo); - // ... perform export using credentials owned by this codeunit - end; - - local procedure CallerIsAuthorizedToExport(CustomerNo: Code[20]): Boolean - begin - // Authorization decision stays inside the publisher. Subscribers - // receive only the customer number and cannot influence the - // decision. - end; -} 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 deleted file mode 100644 index b8907a8..0000000 --- a/microsoft/knowledge/security/do-not-expose-sensitive-data-in-event-publishers.md +++ /dev/null @@ -1,29 +0,0 @@ ---- -bc-version: [26..28] -domain: security -keywords: [event, publisher, extensibility, var-parameter] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Do not expose sensitive data in event publishers - -> **Seed article.** Converted from an existing security-review prompt to bootstrap the BCQuality security corpus. Domain stewards should expand, restructure, and refine as needed. - -## Description - -Events in AL are extensibility contracts. Every subscriber — third-party, internal, or installed after the fact — receives the full set of event parameters. Parameters that carry secrets, pre-authorization state, or variables the publisher relies on for access control effectively become public, and var-parameters can be mutated by a subscriber to alter publisher behaviour. - -## 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. - -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. - -See sample: `do-not-expose-sensitive-data-in-event-publishers.bad.al`. - diff --git a/microsoft/knowledge/security/do-not-put-credentials-in-urls.bad.al b/microsoft/knowledge/security/do-not-put-credentials-in-urls.bad.al deleted file mode 100644 index 12476bb..0000000 --- a/microsoft/knowledge/security/do-not-put-credentials-in-urls.bad.al +++ /dev/null @@ -1,10 +0,0 @@ -codeunit 50223 "Sec Sample UrlCreds Bad" -{ - procedure Call(ApiKey: Text) - var - Client: HttpClient; - Response: HttpResponseMessage; - begin - Client.Get('https://api.example.com/v1/items?api_key=' + ApiKey, Response); - end; -} diff --git a/microsoft/knowledge/security/do-not-put-credentials-in-urls.good.al b/microsoft/knowledge/security/do-not-put-credentials-in-urls.good.al deleted file mode 100644 index c26dd29..0000000 --- a/microsoft/knowledge/security/do-not-put-credentials-in-urls.good.al +++ /dev/null @@ -1,13 +0,0 @@ -codeunit 50222 "Sec Sample UrlCreds Good" -{ - procedure Call(ApiKey: SecretText) - var - Client: HttpClient; - Response: HttpResponseMessage; - AuthHeader: SecretText; - begin - AuthHeader := SecretStrSubstNo('Bearer %1', ApiKey); - Client.DefaultRequestHeaders.Add('Authorization', AuthHeader); - Client.Get('https://api.example.com/v1/items', Response); - end; -} diff --git a/microsoft/knowledge/security/do-not-put-credentials-in-urls.md b/microsoft/knowledge/security/do-not-put-credentials-in-urls.md deleted file mode 100644 index dc3843e..0000000 --- a/microsoft/knowledge/security/do-not-put-credentials-in-urls.md +++ /dev/null @@ -1,29 +0,0 @@ ---- -bc-version: [26..28] -domain: security -keywords: [url, query-string, credentials, logging] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Do not put credentials in URLs - -> **Seed article.** Converted from an existing security-review prompt to bootstrap the BCQuality security corpus. Domain stewards should expand, restructure, and refine as needed. - -## Description - -URL query strings and path segments are routinely captured in web-server access logs, browser history, proxy logs, platform telemetry, and exception traces. A credential placed anywhere in the URL therefore persists across systems the extension does not control, and is typically retained far longer than the secret's intended lifetime. - -## Best Practice - -Transport credentials in Authorization headers, carried as SecretText end-to-end (see use-secrettext-with-httpclient). Where the URI itself must carry a secret (for example, a pre-signed URL), build it with SecretStrSubstNo and pass it via SetSecretRequestUri so it is never materialized as Text. - -See sample: `do-not-put-credentials-in-urls.good.al`. - -## Anti Pattern - -Appending '?api_key=' + Key to a request URL, or embedding a token in a path segment, then calling HttpClient.Get with the resulting Text URL. - -See sample: `do-not-put-credentials-in-urls.bad.al`. - diff --git a/microsoft/knowledge/security/do-not-swallow-security-errors-silently.bad.al b/microsoft/knowledge/security/do-not-swallow-security-errors-silently.bad.al deleted file mode 100644 index 923df57..0000000 --- a/microsoft/knowledge/security/do-not-swallow-security-errors-silently.bad.al +++ /dev/null @@ -1,15 +0,0 @@ -codeunit 50227 "Sec Sample SwallowErr Bad" -{ - procedure Authenticate(): Boolean - begin - if not TryAuthenticate() then - exit(false); - exit(true); - end; - - [TryFunction] - local procedure TryAuthenticate() - begin - // ... - end; -} diff --git a/microsoft/knowledge/security/do-not-swallow-security-errors-silently.good.al b/microsoft/knowledge/security/do-not-swallow-security-errors-silently.good.al deleted file mode 100644 index 2887312..0000000 --- a/microsoft/knowledge/security/do-not-swallow-security-errors-silently.good.al +++ /dev/null @@ -1,24 +0,0 @@ -codeunit 50226 "Sec Sample SwallowErr Good" -{ - procedure Authenticate(): Boolean - begin - if TryAuthenticate() then - exit(true); - - LogAuthFailure(GetLastErrorText()); - exit(false); - end; - - [TryFunction] - local procedure TryAuthenticate() - begin - // ... - end; - - local procedure LogAuthFailure(Detail: Text) - begin - Session.LogMessage('SEC0001', 'Authentication failed', Verbosity::Warning, - DataClassification::SystemMetadata, TelemetryScope::ExtensionPublisher, - 'Detail', Detail); - end; -} diff --git a/microsoft/knowledge/security/do-not-swallow-security-errors-silently.md b/microsoft/knowledge/security/do-not-swallow-security-errors-silently.md deleted file mode 100644 index fa4af42..0000000 --- a/microsoft/knowledge/security/do-not-swallow-security-errors-silently.md +++ /dev/null @@ -1,29 +0,0 @@ ---- -bc-version: [26..28] -domain: security -keywords: [tryfunction, logging, audit, error] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Do not swallow security errors silently - -> **Seed article.** Converted from an existing security-review prompt to bootstrap the BCQuality security corpus. Domain stewards should expand, restructure, and refine as needed. - -## Description - -Authentication failures, permission denials, and unexpected error paths in security-relevant code are the signals a reviewer or incident responder needs to see. A TryFunction whose failure is ignored without logging turns an attack or a misconfiguration into silent bad behaviour: the call returns false, the caller moves on, and no record of the event survives. - -## Best Practice - -Use TryFunctions to contain errors around security-relevant work, but always log the failure (category, GetLastErrorText, and enough context to identify the operation) before deciding whether to surface a user-facing error. Never discard a caught security error without a trace. - -See sample: `do-not-swallow-security-errors-silently.good.al`. - -## Anti Pattern - -`if not TryAuthenticate() then exit;` with no logging and no user-facing error. An authentication-bypass attempt, a revoked credential, and a transient network glitch are now indistinguishable. - -See sample: `do-not-swallow-security-errors-silently.bad.al`. - diff --git a/microsoft/knowledge/security/follow-least-privilege-in-permission-sets.bad.al b/microsoft/knowledge/security/follow-least-privilege-in-permission-sets.bad.al deleted file mode 100644 index 3cdde58..0000000 --- a/microsoft/knowledge/security/follow-least-privilege-in-permission-sets.bad.al +++ /dev/null @@ -1,6 +0,0 @@ -permissionset 50201 "Sec Sample Full Access" -{ - Assignable = true; - Caption = 'Full Access (sample anti-pattern)'; - Permissions = tabledata * = RIMD; -} diff --git a/microsoft/knowledge/security/follow-least-privilege-in-permission-sets.good.al b/microsoft/knowledge/security/follow-least-privilege-in-permission-sets.good.al deleted file mode 100644 index d7ad6bd..0000000 --- a/microsoft/knowledge/security/follow-least-privilege-in-permission-sets.good.al +++ /dev/null @@ -1,9 +0,0 @@ -permissionset 50200 "Sec Sample Sales Order Entry" -{ - Assignable = true; - Caption = 'Sales Order Entry (sample)'; - Permissions = - tabledata "Sales Header" = RIM, - tabledata "Sales Line" = RIMD, - tabledata Customer = R; -} diff --git a/microsoft/knowledge/security/follow-least-privilege-in-permission-sets.md b/microsoft/knowledge/security/follow-least-privilege-in-permission-sets.md deleted file mode 100644 index d33c7be..0000000 --- a/microsoft/knowledge/security/follow-least-privilege-in-permission-sets.md +++ /dev/null @@ -1,29 +0,0 @@ ---- -bc-version: [26..28] -domain: security -keywords: [permissionset, least-privilege, rimd, tabledata] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Follow least privilege in permission sets - -> **Seed article.** Converted from an existing security-review prompt to bootstrap the BCQuality security corpus. Domain stewards should expand, restructure, and refine as needed. - -## Description - -Permission sets define the tabledata and object rights granted to every user or role assigned to them. A permission set that grants RIMD on tabledata * hands every caller full control over every table the extension exposes, which is never the shape of access any real role requires. Over-broad permission sets are a persistent source of privilege-escalation risk: once assigned, they are rarely audited. - -## Best Practice - -Enumerate the specific tabledata objects a role needs and grant only the letters (R, I, M, D) that role genuinely uses. A sales order-entry role typically needs RIM on Sales Header, RIMD on Sales Line, and R on Customer — not blanket RIMD. Permission sets SHOULD be granular and role-shaped; a single permission set that covers every role in an extension is a design smell. - -See sample: `follow-least-privilege-in-permission-sets.good.al`. - -## Anti Pattern - -Granting `tabledata * = RIMD` (or any wildcard with I, M, or D) in a permission set. This bypasses any meaningful separation of duties the extension could enforce and gives unreviewed code paths the ability to insert, modify, and delete on any table. - -See sample: `follow-least-privilege-in-permission-sets.bad.al`. - diff --git a/microsoft/knowledge/security/getlasterrortext-storage-is-privacy-not-security.bad.al b/microsoft/knowledge/security/getlasterrortext-storage-is-privacy-not-security.bad.al new file mode 100644 index 0000000..8a8c025 --- /dev/null +++ b/microsoft/knowledge/security/getlasterrortext-storage-is-privacy-not-security.bad.al @@ -0,0 +1,10 @@ +codeunit 50234 "Sec Sample LastErrText" +{ + procedure RunWithCapture(var ErrorLog: Record "Integration Log") + begin + if not Codeunit.Run(Codeunit::"My Worker") then begin + ErrorLog."Error Text" := CopyStr(GetLastErrorText(), 1, MaxStrLen(ErrorLog."Error Text")); + ErrorLog.Insert(true); + end; + end; +} diff --git a/microsoft/knowledge/security/getlasterrortext-storage-is-privacy-not-security.md b/microsoft/knowledge/security/getlasterrortext-storage-is-privacy-not-security.md new file mode 100644 index 0000000..4e9da1d --- /dev/null +++ b/microsoft/knowledge/security/getlasterrortext-storage-is-privacy-not-security.md @@ -0,0 +1,22 @@ +--- +bc-version: [all] +domain: security +keywords: [getlasterrortext, error-text, classification, privacy, review-scope] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Storing GetLastErrorText() in table fields is a privacy finding, not a security finding + +## Description + +It is tempting to flag any code that calls `GetLastErrorText()` and writes the result into a table field (or displays it to end users) as a security issue, on the assumption that the error text might leak credentials or system internals. In Business Central, that pattern is treated as a **privacy** concern instead: AL `Error` text frequently contains customer content (record keys, field values, document numbers) rather than infrastructure details, and the appropriate review owner is the privacy/DataClassification reviewer. A security reviewer should not raise a finding for `GetLastErrorText()` storage on the grounds that it might expose secrets; that risk is covered elsewhere by the rules that prevent secrets from appearing in error messages in the first place (see `secrettext-for-credentials.md`). + +## Best Practice + +When auditing AL changes for security, ignore patterns where `GetLastErrorText()` is captured into a table or shown to users — leave those to the privacy review. Security findings on error text should be limited to the construction of the `Error()` call itself: secrets, paths, or technical internals being interpolated into the error before it is raised. See sample: `getlasterrortext-storage-is-privacy-not-security.bad.al` for the pattern that is *not* a security finding. + +## Anti Pattern + +Filing a security finding such as "GetLastErrorText() stored in field — potential information disclosure" against AL code that captures an error for later inspection. The finding is in the wrong domain and crowds out the actual security signal. The mirror anti-pattern is silencing genuine `Error('... %1 ...', SecretValue)` constructions on the grounds that "error text is privacy" — those *are* security findings because they create the leak, regardless of where the text ends up afterwards. diff --git a/microsoft/knowledge/security/indirect-permissions-for-elevated-access.bad.al b/microsoft/knowledge/security/indirect-permissions-for-elevated-access.bad.al new file mode 100644 index 0000000..37fe2a6 --- /dev/null +++ b/microsoft/knowledge/security/indirect-permissions-for-elevated-access.bad.al @@ -0,0 +1,4 @@ +permissionset 50204 "Sec Sample Report Runner Bad" +{ + Permissions = tabledata "G/L Entry" = RIMD; +} diff --git a/microsoft/knowledge/security/indirect-permissions-for-elevated-access.good.al b/microsoft/knowledge/security/indirect-permissions-for-elevated-access.good.al new file mode 100644 index 0000000..9fef5e4 --- /dev/null +++ b/microsoft/knowledge/security/indirect-permissions-for-elevated-access.good.al @@ -0,0 +1,4 @@ +permissionset 50203 "Sec Sample Report Runner" +{ + Permissions = tabledata "G/L Entry" = ri; +} diff --git a/microsoft/knowledge/security/indirect-permissions-for-elevated-access.md b/microsoft/knowledge/security/indirect-permissions-for-elevated-access.md new file mode 100644 index 0000000..206d211 --- /dev/null +++ b/microsoft/knowledge/security/indirect-permissions-for-elevated-access.md @@ -0,0 +1,22 @@ +--- +bc-version: [all] +domain: security +keywords: [permissionset, indirect-permissions, ri, ii, mi, di, code-mediated] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Use indirect permissions when access must be code-mediated + +## Description + +In a `permissionset`, uppercase letters (`R`, `I`, `M`, `D`) grant **direct** permissions: the assignee can read, insert, modify, or delete the table data through any UI or API surface. Lowercase letters (`r`, `i`, `m`, `d`) grant **indirect** permissions: the operation is allowed only when it is invoked from AL code that itself holds the corresponding direct permission. Indirect permissions let a role consume privileged tables through controlled procedures (a report, a posting routine) without giving users a way to read or change those tables outside the intended code path. + +## Best Practice + +Use indirect permissions (`ri`, `ii`, `mi`, `di`) when a role needs access to a sensitive table only through a specific codeunit or report — for example, a "Report Runner" role that reads `G/L Entry` only via published reports. Pair the indirect grant with the codeunit or report that mediates access; that object's own permissions (or InherentPermissions) supply the direct rights. Document why indirect permissions are required in the permission set or in the consuming object's comments. See sample: `indirect-permissions-for-elevated-access.good.al`. + +## Anti Pattern + +Granting `RIMD` on a sensitive table when the role only needs to view it through a report — for example `tabledata "G/L Entry" = RIMD` on a "Report Runner" role. Users assigned that role can now query and modify ledger entries directly through any client that respects the permission, bypassing the report entirely. Reviewers should look for uppercase grants on system-of-record tables (G/L Entry, ledger entries, posted documents) where the consuming code path is clearly read-through-report or read-through-API. See sample: `indirect-permissions-for-elevated-access.bad.al`. diff --git a/microsoft/knowledge/security/inherent-permissions-minimal-grant.bad.al b/microsoft/knowledge/security/inherent-permissions-minimal-grant.bad.al new file mode 100644 index 0000000..a75b336 --- /dev/null +++ b/microsoft/knowledge/security/inherent-permissions-minimal-grant.bad.al @@ -0,0 +1,20 @@ +codeunit 50206 "Sec Sample Inherent Bad" +{ + [InherentPermissions(PermissionObjectType::TableData, Database::"Sales Header", 'RIMD')] + [InherentEntitlements(Entitlement::"Dynamics 365 Business Central Premium")] + procedure GetCustomerName(CustomerNo: Code[20]): Text + var + Customer: Record Customer; + begin + if Customer.Get(CustomerNo) then + exit(Customer.Name); + end; + + [InherentEntitlements(Entitlement::"Dynamics 365 Business Central Premium")] + procedure CheckItemExists(ItemNo: Code[20]): Boolean + var + Item: Record Item; + begin + exit(Item.Get(ItemNo)); + end; +} diff --git a/microsoft/knowledge/security/inherent-permissions-minimal-grant.good.al b/microsoft/knowledge/security/inherent-permissions-minimal-grant.good.al new file mode 100644 index 0000000..e6d903e --- /dev/null +++ b/microsoft/knowledge/security/inherent-permissions-minimal-grant.good.al @@ -0,0 +1,19 @@ +codeunit 50205 "Sec Sample Inherent Good" +{ + [InherentPermissions(PermissionObjectType::TableData, Database::Customer, 'r')] + procedure GetCustomerName(CustomerNo: Code[20]): Text + var + Customer: Record Customer; + begin + if Customer.Get(CustomerNo) then + exit(Customer.Name); + end; + + [InherentPermissions(PermissionObjectType::TableData, Database::Item, 'r')] + procedure CheckItemExists(ItemNo: Code[20]): Boolean + var + Item: Record Item; + begin + exit(Item.Get(ItemNo)); + end; +} diff --git a/microsoft/knowledge/security/inherent-permissions-minimal-grant.md b/microsoft/knowledge/security/inherent-permissions-minimal-grant.md new file mode 100644 index 0000000..41ba2a5 --- /dev/null +++ b/microsoft/knowledge/security/inherent-permissions-minimal-grant.md @@ -0,0 +1,22 @@ +--- +bc-version: [all] +domain: security +keywords: [inherentpermissions, inherententitlements, attribute, least-privilege, procedure] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Grant the minimum InherentPermissions a procedure needs + +## Description + +`[InherentPermissions(PermissionObjectType::..., ...)]` and `[InherentEntitlements(Entitlement::...)]` are method-level attributes that let a procedure perform an operation on the listed object even when the caller's permission set does not allow it. They effectively elevate the caller for the duration of the procedure. The grant therefore needs to be as narrow as the procedure's actual work — both in object scope (the specific table) and in operation (`'r'` versus `'RIMD'`). Overly broad inherent permissions silently expand the attack surface of every codeunit that calls the procedure. + +## Best Practice + +Match the inherent permission to the procedure's body: a procedure that only reads `Customer.Name` declares `[InherentPermissions(PermissionObjectType::TableData, Database::Customer, 'r')]`, not `'RIMD'`. Pick the inherent entitlement that matches the lowest tier the procedure should run under — do not require Premium for a procedure that performs an Essential-tier check. See sample: `inherent-permissions-minimal-grant.good.al`. + +## Anti Pattern + +Declaring `[InherentPermissions(..., 'RIMD')]` on a read-only procedure (`GetCustomerName`), or `[InherentEntitlements(Entitlement::"Dynamics 365 Business Central Premium")]` on a procedure that performs a simple existence check. Reviewers should compare the attribute's permission letters against what the procedure body actually does and flag any grant broader than the operations performed. See sample: `inherent-permissions-minimal-grant.bad.al`. diff --git a/microsoft/knowledge/security/integrationevent-must-not-expose-secrets.bad.al b/microsoft/knowledge/security/integrationevent-must-not-expose-secrets.bad.al new file mode 100644 index 0000000..79da066 --- /dev/null +++ b/microsoft/knowledge/security/integrationevent-must-not-expose-secrets.bad.al @@ -0,0 +1,7 @@ +codeunit 50229 "Sec Sample EventSecret Bad" +{ + [IntegrationEvent(false, false)] + local procedure OnBeforeSendRequest(var ApiKey: Text; var Password: Text; var RequestUrl: Text) + begin + end; +} diff --git a/microsoft/knowledge/security/integrationevent-must-not-expose-secrets.good.al b/microsoft/knowledge/security/integrationevent-must-not-expose-secrets.good.al new file mode 100644 index 0000000..b30372b --- /dev/null +++ b/microsoft/knowledge/security/integrationevent-must-not-expose-secrets.good.al @@ -0,0 +1,7 @@ +codeunit 50228 "Sec Sample EventSecret Good" +{ + [IntegrationEvent(false, false)] + local procedure OnBeforeSendRequest(var RequestPayload: JsonObject; var IsHandled: Boolean) + begin + end; +} diff --git a/microsoft/knowledge/security/integrationevent-must-not-expose-secrets.md b/microsoft/knowledge/security/integrationevent-must-not-expose-secrets.md new file mode 100644 index 0000000..1c51f8b --- /dev/null +++ b/microsoft/knowledge/security/integrationevent-must-not-expose-secrets.md @@ -0,0 +1,22 @@ +--- +bc-version: [all] +domain: security +keywords: [integrationevent, eventsubscriber, secrets, credentials, publisher] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Do not pass credentials or secrets through IntegrationEvent parameters + +## Description + +`[IntegrationEvent]` publishes a hook that any extension can subscribe to. Every parameter of the event signature is visible to every subscriber — including `var` parameters, which subscribers can both read and modify. A publisher that includes an API key, password, bearer token, or other secret in the event signature hands that secret to every subscriber on the tenant, including subscribers in extensions the publisher has no relationship with. There is no permission or partner-only filter that limits who may subscribe. + +## Best Practice + +Restrict event payloads to the non-sensitive context a subscriber legitimately needs: the business record being processed (a `Customer`), the operation being performed, an `IsHandled` flag that lets a subscriber skip the default behaviour, and a mutable payload object whose contents the publisher controls. Authentication is handled by the publisher before or after the event, never inside the parameters. See sample: `integrationevent-must-not-expose-secrets.good.al`. + +## Anti Pattern + +`[IntegrationEvent(false, false)] procedure OnBeforeSendRequest(var ApiKey: Text; var Password: Text; var RequestUrl: Text)` — any extension on the tenant can subscribe, read `ApiKey` and `Password`, and persist them elsewhere. Reviewers should flag any event parameter whose name or type suggests a secret (`ApiKey`, `Token`, `Password`, `Secret`, `Credential`, `SecretText` — even `SecretText` should not flow through an event surface). See sample: `integrationevent-must-not-expose-secrets.bad.al`. diff --git a/microsoft/knowledge/security/integrationevent-var-parameter-bypasses-security-guards.bad.al b/microsoft/knowledge/security/integrationevent-var-parameter-bypasses-security-guards.bad.al new file mode 100644 index 0000000..ee1b8cf --- /dev/null +++ b/microsoft/knowledge/security/integrationevent-var-parameter-bypasses-security-guards.bad.al @@ -0,0 +1,19 @@ +codeunit 50231 "Sec Sample EventGuard Bad" +{ + procedure CheckPermissionsForTable(TableNo: Integer) + var + HasAccess: Boolean; + SkipValidation: Boolean; + begin + OnBeforeCheckPermissions(HasAccess, SkipValidation, TableNo); + if SkipValidation then + exit; + if not HasAccess then + Error('Access denied.'); + end; + + [IntegrationEvent(false, false)] + local procedure OnBeforeCheckPermissions(var HasAccess: Boolean; var SkipValidation: Boolean; TableNo: Integer) + begin + end; +} diff --git a/microsoft/knowledge/security/integrationevent-var-parameter-bypasses-security-guards.good.al b/microsoft/knowledge/security/integrationevent-var-parameter-bypasses-security-guards.good.al new file mode 100644 index 0000000..9d17d9e --- /dev/null +++ b/microsoft/knowledge/security/integrationevent-var-parameter-bypasses-security-guards.good.al @@ -0,0 +1,22 @@ +codeunit 50230 "Sec Sample EventGuard Good" +{ + procedure CheckPermissionsForTable(TableNo: Integer) + var + HasAccess: Boolean; + begin + HasAccess := PerformInternalCheck(TableNo); + if not HasAccess then + Error('Access denied.'); + OnAfterCheckPermissions(TableNo, HasAccess); + end; + + local procedure PerformInternalCheck(TableNo: Integer): Boolean + begin + exit(true); + end; + + [IntegrationEvent(false, false)] + local procedure OnAfterCheckPermissions(TableNo: Integer; HasAccess: Boolean) + begin + end; +} diff --git a/microsoft/knowledge/security/integrationevent-var-parameter-bypasses-security-guards.md b/microsoft/knowledge/security/integrationevent-var-parameter-bypasses-security-guards.md new file mode 100644 index 0000000..3429e80 --- /dev/null +++ b/microsoft/knowledge/security/integrationevent-var-parameter-bypasses-security-guards.md @@ -0,0 +1,22 @@ +--- +bc-version: [all] +domain: security +keywords: [integrationevent, var, guard, ishandled, bypass, security-check] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Do not expose security guards as `var` parameters on IntegrationEvent + +## Description + +A `var` parameter on an `[IntegrationEvent]` is a mutable hook: any subscriber can overwrite the value and the publisher will see the new value when control returns. That is the right shape for "let an extension contribute to a payload"; it is the wrong shape for "let an extension confirm a security decision". A `var HasAccess: Boolean` or `var SkipValidation: Boolean` lets any subscriber on the tenant flip the result of the publisher's permission check to `true` (or set "skip" to `true`) before the publisher reads it. The publisher's check becomes advisory, which is the same as not having a check. + +## Best Practice + +Keep the security decision inside the publisher, where it is not bypassable. Fire an `OnAfter*` informational event after the check completes, with the result passed by value (not `var`) so subscribers can react — log, audit, surface a warning — but cannot rewrite the outcome. When subscribers legitimately need to add their own checks, expose an `OnAfterCheckPermissions(...)` that can only tighten access (e.g., a subscriber can `Error()`), never loosen it. See sample: `integrationevent-var-parameter-bypasses-security-guards.good.al`. + +## Anti Pattern + +`OnBeforeCheckPermissions(var HasAccess: Boolean; var SkipValidation: Boolean; TableNo: Integer)`, followed in the caller by `if SkipValidation then exit;`. Any subscriber sets `SkipValidation := true` and the check is gone. Reviewers should flag any `IntegrationEvent` whose signature contains a `var Boolean` whose name reads like a security decision (`HasAccess`, `IsAllowed`, `SkipValidation`, `BypassCheck`, `IsAuthorized`). See sample: `integrationevent-var-parameter-bypasses-security-guards.bad.al`. diff --git a/microsoft/knowledge/security/isolatedstorage-access-must-be-local-or-internal.bad.al b/microsoft/knowledge/security/isolatedstorage-access-must-be-local-or-internal.bad.al new file mode 100644 index 0000000..84297bb --- /dev/null +++ b/microsoft/knowledge/security/isolatedstorage-access-must-be-local-or-internal.bad.al @@ -0,0 +1,16 @@ +codeunit 50216 "Sec Sample IsoStorage Bad" +{ + procedure GetApiKey(): Text + var + ApiKey: Text; + begin + if IsolatedStorage.Contains('ApiKey', DataScope::Module) then + IsolatedStorage.Get('ApiKey', DataScope::Module, ApiKey); + exit(ApiKey); + end; + + procedure SetApiKey(NewKey: Text) + begin + IsolatedStorage.SetEncrypted('ApiKey', NewKey, DataScope::Module); + end; +} diff --git a/microsoft/knowledge/security/isolatedstorage-access-must-be-local-or-internal.good.al b/microsoft/knowledge/security/isolatedstorage-access-must-be-local-or-internal.good.al new file mode 100644 index 0000000..22e279b --- /dev/null +++ b/microsoft/knowledge/security/isolatedstorage-access-must-be-local-or-internal.good.al @@ -0,0 +1,15 @@ +codeunit 50215 "Sec Sample IsoStorage Good" +{ + local procedure GetApiKey(var ApiKey: SecretText): Boolean + begin + if not IsolatedStorage.Contains('ApiKey', DataScope::Module) then + exit(false); + IsolatedStorage.Get('ApiKey', DataScope::Module, ApiKey); + exit(true); + end; + + internal procedure SetApiKey(NewKey: Text) + begin + IsolatedStorage.SetEncrypted('ApiKey', NewKey, DataScope::Module); + end; +} diff --git a/microsoft/knowledge/security/isolatedstorage-access-must-be-local-or-internal.md b/microsoft/knowledge/security/isolatedstorage-access-must-be-local-or-internal.md new file mode 100644 index 0000000..cbf5d5d --- /dev/null +++ b/microsoft/knowledge/security/isolatedstorage-access-must-be-local-or-internal.md @@ -0,0 +1,22 @@ +--- +bc-version: [all] +domain: security +keywords: [isolatedstorage, local, internal, public, getter, setter, encapsulation] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Procedures that read or write IsolatedStorage must not be public + +## Description + +`IsolatedStorage` partitions its data by extension: values written by one extension are unreadable to another. That guarantee assumes the owning extension does not voluntarily expose its storage through a public API. A `public` procedure on a codeunit that calls `IsolatedStorage.Get`, `IsolatedStorage.Set`, `IsolatedStorage.SetEncrypted`, `IsolatedStorage.Contains`, or `IsolatedStorage.Delete` defeats the isolation: any other extension on the same tenant can call that procedure and obtain (or overwrite) the secret. The platform's per-extension boundary becomes a per-procedure boundary, and there is no per-procedure boundary. + +## Best Practice + +Mark every procedure that touches `IsolatedStorage` as `local` (visible only inside its containing object) or `internal` (visible only inside the owning extension). Provide consumers with a narrow, intent-specific API — for example, "send notification to configured webhook" rather than "give me the webhook secret." See sample: `isolatedstorage-access-must-be-local-or-internal.good.al`. + +## Anti Pattern + +A public `GetApiKey()` returning the stored value, or a public `SetApiKey(NewKey: Text)` that calls `IsolatedStorage.SetEncrypted`. Both turn the extension into a confused deputy that hands out (or accepts overwrites of) its own secrets on behalf of any caller on the tenant. Reviewers should flag any procedure whose body references `IsolatedStorage` and whose declaration omits `local` or `internal`. See sample: `isolatedstorage-access-must-be-local-or-internal.bad.al`. diff --git a/microsoft/knowledge/security/isolatedstorage-datascope-module-vs-company.bad.al b/microsoft/knowledge/security/isolatedstorage-datascope-module-vs-company.bad.al new file mode 100644 index 0000000..60efe31 --- /dev/null +++ b/microsoft/knowledge/security/isolatedstorage-datascope-module-vs-company.bad.al @@ -0,0 +1,15 @@ +codeunit 50220 "Sec Sample DataScope Bad" +{ + internal procedure StoreCompanyWebhook(WebhookUrl: Text) + begin + IsolatedStorage.SetEncrypted('WebhookUrl', WebhookUrl, DataScope::Module); + end; + + local procedure ReadCompanyWebhook(var WebhookUrl: SecretText): Boolean + begin + if not IsolatedStorage.Contains('WebhookUrl', DataScope::Company) then + exit(false); + IsolatedStorage.Get('WebhookUrl', DataScope::Company, WebhookUrl); + exit(true); + end; +} diff --git a/microsoft/knowledge/security/isolatedstorage-datascope-module-vs-company.good.al b/microsoft/knowledge/security/isolatedstorage-datascope-module-vs-company.good.al new file mode 100644 index 0000000..397635f --- /dev/null +++ b/microsoft/knowledge/security/isolatedstorage-datascope-module-vs-company.good.al @@ -0,0 +1,20 @@ +codeunit 50219 "Sec Sample DataScope Good" +{ + internal procedure StoreTenantApiKey(ApiKey: Text) + begin + IsolatedStorage.SetEncrypted('TenantApiKey', ApiKey, DataScope::Module); + end; + + internal procedure StoreCompanyWebhook(WebhookUrl: Text) + begin + IsolatedStorage.SetEncrypted('WebhookUrl', WebhookUrl, DataScope::Company); + end; + + local procedure ReadCompanyWebhook(var WebhookUrl: SecretText): Boolean + begin + if not IsolatedStorage.Contains('WebhookUrl', DataScope::Company) then + exit(false); + IsolatedStorage.Get('WebhookUrl', DataScope::Company, WebhookUrl); + exit(true); + end; +} diff --git a/microsoft/knowledge/security/isolatedstorage-datascope-module-vs-company.md b/microsoft/knowledge/security/isolatedstorage-datascope-module-vs-company.md new file mode 100644 index 0000000..711895d --- /dev/null +++ b/microsoft/knowledge/security/isolatedstorage-datascope-module-vs-company.md @@ -0,0 +1,22 @@ +--- +bc-version: [all] +domain: security +keywords: [isolatedstorage, datascope, module, company, user, scope] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Pick the right IsolatedStorage DataScope for the secret's lifetime + +## Description + +`IsolatedStorage` read and write methods take a `DataScope` parameter that decides which slice of storage the value belongs to. The choice is not a stylistic one — it changes which callers, in which company and under which user, can read the value back. Two scopes cover the common cases for app-level secrets: `DataScope::Module` stores the value once for the whole extension, isolated to that extension on the tenant — the right scope for app-specific secrets such as a global API key or service account. `DataScope::Company` stores the value per company, so each company on the tenant has its own slot — the right scope for company-specific secrets such as a per-company webhook URL or a per-company integration token. A per-user scope also exists for values that belong to an individual user. + +## Best Practice + +Choose `Module` when the secret is the same for every company and every user under the extension (a single tenant-wide API key). Choose `Company` when each company has its own integration credentials. Choose the user scope only when the secret is genuinely per-user. Use the same `DataScope` value on `Set`/`SetEncrypted`, `Get`, `Contains`, and `Delete` for the same key — mixing scopes for the same logical secret produces silent "not found" results. See sample: `isolatedstorage-datascope-module-vs-company.good.al`. + +## Anti Pattern + +Defaulting every call to `DataScope::Module` regardless of intent — storing a per-company webhook URL under `Module` means every company on the tenant shares the same URL. Or the inverse: storing a tenant-wide API key under `Company` means each company-switch effectively loses the key. Reviewers should look for cross-method inconsistency (`Set` under `Module`, `Get` under `Company`) and for scope choices that contradict the value's documented lifetime. See sample: `isolatedstorage-datascope-module-vs-company.bad.al`. diff --git a/microsoft/knowledge/security/isolatedstorage-setencrypted-for-sensitive-values.bad.al b/microsoft/knowledge/security/isolatedstorage-setencrypted-for-sensitive-values.bad.al new file mode 100644 index 0000000..dc75430 --- /dev/null +++ b/microsoft/knowledge/security/isolatedstorage-setencrypted-for-sensitive-values.bad.al @@ -0,0 +1,7 @@ +codeunit 50218 "Sec Sample SetEncrypted Bad" +{ + internal procedure StoreApiKey(ApiKeyValue: Text) + begin + IsolatedStorage.Set('ApiKey', ApiKeyValue, DataScope::Module); + end; +} diff --git a/microsoft/knowledge/security/isolatedstorage-setencrypted-for-sensitive-values.good.al b/microsoft/knowledge/security/isolatedstorage-setencrypted-for-sensitive-values.good.al new file mode 100644 index 0000000..215055c --- /dev/null +++ b/microsoft/knowledge/security/isolatedstorage-setencrypted-for-sensitive-values.good.al @@ -0,0 +1,17 @@ +codeunit 50217 "Sec Sample SetEncrypted Good" +{ + internal procedure StoreApiKey(ApiKeyValue: Text) + begin + if StrLen(ApiKeyValue) > 200 then + Error('API key too long for encrypted storage'); + IsolatedStorage.SetEncrypted('ApiKey', ApiKeyValue, DataScope::Module); + end; + + local procedure ReadApiKey(var ApiKey: SecretText): Boolean + begin + if not IsolatedStorage.Contains('ApiKey', DataScope::Module) then + exit(false); + IsolatedStorage.Get('ApiKey', DataScope::Module, ApiKey); + exit(true); + end; +} diff --git a/microsoft/knowledge/security/isolatedstorage-setencrypted-for-sensitive-values.md b/microsoft/knowledge/security/isolatedstorage-setencrypted-for-sensitive-values.md new file mode 100644 index 0000000..4e4f6b9 --- /dev/null +++ b/microsoft/knowledge/security/isolatedstorage-setencrypted-for-sensitive-values.md @@ -0,0 +1,22 @@ +--- +bc-version: [all] +domain: security +keywords: [isolatedstorage, setencrypted, encryption, secret, storage] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Prefer IsolatedStorage.SetEncrypted over Set for sensitive values + +## Description + +`IsolatedStorage` exposes two write entry points: `Set` stores the value as-is, and `SetEncrypted` stores it encrypted at rest. Both are scoped per extension, but only `SetEncrypted` adds the additional protection that the value is not readable from the underlying storage by anything that bypasses the AL `IsolatedStorage` API. The choice between them is by intent: configuration that is not sensitive (a user preference, a default flag) can use `Set`; anything that would harm the tenant if leaked — API keys, tokens, connection strings, OAuth client secrets — uses `SetEncrypted`. + +## Best Practice + +Use `IsolatedStorage.SetEncrypted` for every value that meets the definition of a secret. Pair it with the matching retrieval pattern: `IsolatedStorage.Contains` to test for presence and `IsolatedStorage.Get` (preferably with a `SecretText` destination) to read. Constrain the input length before storing — long values can exceed the encrypted-storage size limit and the write will fail at runtime. See sample: `isolatedstorage-setencrypted-for-sensitive-values.good.al`. + +## Anti Pattern + +`IsolatedStorage.Set('ApiKey', ApiKeyValue, DataScope::Module)` — the key is now sitting in storage unencrypted, and any future incident that exposes the underlying storage exposes the key. Reviewers should flag any `IsolatedStorage.Set` whose key name or surrounding context suggests a secret (`ApiKey`, `Token`, `Password`, `Secret`, `ClientSecret`). See sample: `isolatedstorage-setencrypted-for-sensitive-values.bad.al`. diff --git a/microsoft/knowledge/security/never-hardcode-secrets-in-al.bad.al b/microsoft/knowledge/security/never-hardcode-secrets-in-al.bad.al deleted file mode 100644 index 841a809..0000000 --- a/microsoft/knowledge/security/never-hardcode-secrets-in-al.bad.al +++ /dev/null @@ -1,10 +0,0 @@ -codeunit 50207 "Sec Sample HardcodedSecret Bad" -{ - var - HardcodedApiKeyLbl: Label 'sk-live-1234567890abcdef', Locked = true; - - procedure GetApiKey(): Text - begin - exit(HardcodedApiKeyLbl); - end; -} diff --git a/microsoft/knowledge/security/never-hardcode-secrets-in-al.good.al b/microsoft/knowledge/security/never-hardcode-secrets-in-al.good.al deleted file mode 100644 index 835bbbd..0000000 --- a/microsoft/knowledge/security/never-hardcode-secrets-in-al.good.al +++ /dev/null @@ -1,12 +0,0 @@ -codeunit 50206 "Sec Sample HardcodedSecret Good" -{ - procedure GetApiKey() ApiKey: SecretText - var - StoredValue: SecretText; - begin - if IsolatedStorage.Contains('ApiKey', DataScope::Module) then - if IsolatedStorage.Get('ApiKey', DataScope::Module, StoredValue) then - exit(StoredValue); - Error('API key is not configured.'); - end; -} diff --git a/microsoft/knowledge/security/never-hardcode-secrets-in-al.md b/microsoft/knowledge/security/never-hardcode-secrets-in-al.md deleted file mode 100644 index 4be50aa..0000000 --- a/microsoft/knowledge/security/never-hardcode-secrets-in-al.md +++ /dev/null @@ -1,29 +0,0 @@ ---- -bc-version: [26..28] -domain: security -keywords: [secrets, credentials, hardcoded, label, apikey] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Never hardcode secrets in AL - -> **Seed article.** Converted from an existing security-review prompt to bootstrap the BCQuality security corpus. Domain stewards should expand, restructure, and refine as needed. - -## Description - -A secret embedded in AL source — API key, password, connection string, token — lives forever: in the app package, in source control history, in every debugger session that sees the assignment, and in any log that captures the containing variable. Rotation is effectively impossible without a new release, and the blast radius covers every tenant the extension is installed in. - -## Best Practice - -Retrieve secrets at runtime from a protected store: Azure Key Vault for production workloads (see prefer-azure-key-vault-for-production-secrets) or IsolatedStorage for tenant-local encrypted values (see use-isolated-storage-for-module-and-company-secrets). Carry the retrieved value in a SecretText variable end-to-end (see use-secrettext-for-credentials). - -See sample: `never-hardcode-secrets-in-al.good.al`. - -## Anti Pattern - -Assigning a secret literal to a Text, Code, or Label variable (including labels marked as constants). The secret is now part of the compiled app and indistinguishable from non-sensitive content to callers and tools. - -See sample: `never-hardcode-secrets-in-al.bad.al`. - diff --git a/microsoft/knowledge/security/nondebuggable-required-when-unwrapping-secrettext.bad.al b/microsoft/knowledge/security/nondebuggable-required-when-unwrapping-secrettext.bad.al new file mode 100644 index 0000000..a22b60f --- /dev/null +++ b/microsoft/knowledge/security/nondebuggable-required-when-unwrapping-secrettext.bad.al @@ -0,0 +1,19 @@ +codeunit 50214 "Sec Sample NonDebug Bad" +{ + procedure BuildConnectionString(ApiKey: SecretText): Text + begin + exit('Server=db.example.com;Key=' + ApiKey.Unwrap()); + end; + + procedure ParseSessionToken(Response: HttpResponseMessage; var SessionToken: SecretText) + var + ResponseText: Text; + JsonObject: JsonObject; + JsonToken: JsonToken; + begin + Response.Content.ReadAs(ResponseText); + JsonObject.ReadFrom(ResponseText); + JsonObject.Get('access_token', JsonToken); + SessionToken := JsonToken.AsValue().AsText(); + end; +} diff --git a/microsoft/knowledge/security/nondebuggable-required-when-unwrapping-secrettext.good.al b/microsoft/knowledge/security/nondebuggable-required-when-unwrapping-secrettext.good.al new file mode 100644 index 0000000..421e9bd --- /dev/null +++ b/microsoft/knowledge/security/nondebuggable-required-when-unwrapping-secrettext.good.al @@ -0,0 +1,21 @@ +codeunit 50213 "Sec Sample NonDebug Good" +{ + [NonDebuggable] + procedure BuildConnectionString(ApiKey: SecretText): Text + begin + exit('Server=db.example.com;Key=' + ApiKey.Unwrap()); + end; + + [NonDebuggable] + procedure ParseSessionToken(Response: HttpResponseMessage; var SessionToken: SecretText) + var + ResponseText: Text; + JsonObject: JsonObject; + JsonToken: JsonToken; + begin + Response.Content.ReadAs(ResponseText); + JsonObject.ReadFrom(ResponseText); + JsonObject.Get('access_token', JsonToken); + SessionToken := JsonToken.AsValue().AsText(); + end; +} diff --git a/microsoft/knowledge/security/nondebuggable-required-when-unwrapping-secrettext.md b/microsoft/knowledge/security/nondebuggable-required-when-unwrapping-secrettext.md new file mode 100644 index 0000000..b214977 --- /dev/null +++ b/microsoft/knowledge/security/nondebuggable-required-when-unwrapping-secrettext.md @@ -0,0 +1,22 @@ +--- +bc-version: [all] +domain: security +keywords: [nondebuggable, attribute, secrettext, unwrap, debugger] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Mark procedures that call SecretText.Unwrap() as [NonDebuggable] + +## Description + +`SecretText` transit — assignment, parameter passing, and return values — is auto-protected: the debugger sees a redacted placeholder, not the value. The protection ends the moment code calls `.Unwrap()`, which converts the `SecretText` back to plain `Text`. From that point on, the local variable holding the result is visible in the debugger like any other `Text`. The `[NonDebuggable]` attribute marks a procedure so that none of its locals or parameters are visible to the debugger during execution, which is exactly what is needed for any procedure that performs an `Unwrap()` or that otherwise materializes a secret as `Text` (for example, while parsing a JSON response to extract an access token). + +## Best Practice + +Apply `[NonDebuggable]` to any procedure whose body calls `.Unwrap()` on a `SecretText`, and to any procedure that constructs a `SecretText` from a `Text` source (such as a procedure that reads a JSON response body and converts the resulting `Text` into a `SecretText` for the caller). Keep the unwrap window as small as possible — ideally a single one-line helper that hands the unwrapped value straight to the consuming API. See sample: `nondebuggable-required-when-unwrapping-secrettext.good.al`. + +## Anti Pattern + +Calling `ApiKey.Unwrap()` inside a procedure that is not marked `[NonDebuggable]`. The unwrapped value is now an ordinary `Text` local and the debugger will display it, defeating the purpose of using `SecretText` in the first place. Reviewers should flag any `Unwrap()` call in a procedure that lacks the attribute, and any procedure that parses a credential out of a response (`access_token`, `id_token`, `client_secret`) without the attribute. See sample: `nondebuggable-required-when-unwrapping-secrettext.bad.al`. diff --git a/microsoft/knowledge/security/permission-set-avoid-wildcard-grants.bad.al b/microsoft/knowledge/security/permission-set-avoid-wildcard-grants.bad.al new file mode 100644 index 0000000..054892d --- /dev/null +++ b/microsoft/knowledge/security/permission-set-avoid-wildcard-grants.bad.al @@ -0,0 +1,10 @@ +permissionset 50201 "Sec Sample Full Access" +{ + Permissions = tabledata * = RIMD; +} + +permissionset 50202 "Sec Sample Basic User" +{ + Permissions = table * = X, + tabledata * = R; +} diff --git a/microsoft/knowledge/security/permission-set-avoid-wildcard-grants.good.al b/microsoft/knowledge/security/permission-set-avoid-wildcard-grants.good.al new file mode 100644 index 0000000..5a3c8a3 --- /dev/null +++ b/microsoft/knowledge/security/permission-set-avoid-wildcard-grants.good.al @@ -0,0 +1,8 @@ +permissionset 50200 "Sec Sample Sales Entry" +{ + Permissions = tabledata "Sales Header" = RIM, + tabledata "Sales Line" = RIMD, + tabledata Customer = R, + table "Sales Header" = X, + table "Sales Line" = X; +} diff --git a/microsoft/knowledge/security/permission-set-avoid-wildcard-grants.md b/microsoft/knowledge/security/permission-set-avoid-wildcard-grants.md new file mode 100644 index 0000000..20332b2 --- /dev/null +++ b/microsoft/knowledge/security/permission-set-avoid-wildcard-grants.md @@ -0,0 +1,22 @@ +--- +bc-version: [all] +domain: security +keywords: [permissionset, wildcard, rimd, tabledata, least-privilege] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Avoid wildcard grants in permission sets + +## Description + +A `permissionset` object can grant access object-by-object or with the `*` wildcard. Wildcard grants — `tabledata * = RIMD` (Read/Insert/Modify/Delete on every table) and `table * = X` (Execute on every table object) — collapse the principle of least privilege into a single line and are almost never what the author intended. The grant binds for the lifetime of the permission set wherever it is assigned, including indirectly via role assignment. Permission sets should be granular and role-specific, enumerating only the objects the role actually needs. + +## Best Practice + +Enumerate each `tabledata` and each `table` entry explicitly. Grant only the letters required: `R` for read-only consumers, `RIM` for editors that do not delete, `RIMD` only for owners of the data. When a role needs Execute on objects, list those objects rather than using `table *`. See sample: `permission-set-avoid-wildcard-grants.good.al`. + +## Anti Pattern + +`Permissions = tabledata * = RIMD;` and `Permissions = table * = X, tabledata * = R;` — both grant access to objects the role's author never inspected, and the grant silently broadens every time a new table ships in the platform or in another extension. Reviewers should flag any `*` on the left-hand side of a `tabledata` or `table` entry. See sample: `permission-set-avoid-wildcard-grants.bad.al`. diff --git a/microsoft/knowledge/security/prefer-azure-key-vault-for-production-secrets.md b/microsoft/knowledge/security/prefer-azure-key-vault-for-production-secrets.md deleted file mode 100644 index bffe8c5..0000000 --- a/microsoft/knowledge/security/prefer-azure-key-vault-for-production-secrets.md +++ /dev/null @@ -1,25 +0,0 @@ ---- -bc-version: [26..28] -domain: security -keywords: [keyvault, azure, secrets, rotation, audit] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Prefer Azure Key Vault for production secrets - -> **Seed article.** Converted from an existing security-review prompt to bootstrap the BCQuality security corpus. Domain stewards should expand, restructure, and refine as needed. - -## Description - -Azure Key Vault is an external secret store that supports central management, rotation, and access auditing. The Business Central system application exposes integration APIs that retrieve Key Vault secrets at runtime. IsolatedStorage, by contrast, is a per-tenant local encrypted store with no central rotation or audit story. - -## Best Practice - -For production workloads that require secret rotation, access auditing, and separation between secret custodians and app developers, Azure Key Vault SHOULD be the store of record. Retrieve secrets into a SecretText variable on demand, cache only as long as the call requires, and never persist the retrieved plaintext anywhere the extension does not control. IsolatedStorage MAY be used when a per-tenant local encrypted store is all that is required. - -## Anti Pattern - -Treating IsolatedStorage as the long-term home for secrets in a multi-tenant production extension where secret rotation, central revocation, or access auditing are required. - diff --git a/microsoft/knowledge/security/recordref-open-with-caller-table-must-not-be-public.bad.al b/microsoft/knowledge/security/recordref-open-with-caller-table-must-not-be-public.bad.al new file mode 100644 index 0000000..d0977e4 --- /dev/null +++ b/microsoft/knowledge/security/recordref-open-with-caller-table-must-not-be-public.bad.al @@ -0,0 +1,12 @@ +codeunit 50233 "Sec Sample RecRef 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/recordref-open-with-caller-table-must-not-be-public.good.al b/microsoft/knowledge/security/recordref-open-with-caller-table-must-not-be-public.good.al new file mode 100644 index 0000000..9bd4bec --- /dev/null +++ b/microsoft/knowledge/security/recordref-open-with-caller-table-must-not-be-public.good.al @@ -0,0 +1,29 @@ +codeunit 50232 "Sec Sample RecRef Good" +{ + internal procedure ArchiveRecord(RecId: RecordId) + var + RecRef: RecordRef; + begin + RecRef.Open(RecId.TableNo); + RecRef.Get(RecId); + RecRef.Delete(); + RecRef.Close(); + end; + + procedure ArchiveAllowedRecord(RecId: RecordId) + var + RecRef: RecordRef; + begin + if not IsAllowedTable(RecId.TableNo) then + Error('Operation not permitted on this table.'); + RecRef.Open(RecId.TableNo); + RecRef.Get(RecId); + RecRef.Delete(); + RecRef.Close(); + end; + + local procedure IsAllowedTable(TableNo: Integer): Boolean + begin + exit(TableNo in [Database::Customer, Database::Vendor]); + end; +} diff --git a/microsoft/knowledge/security/recordref-open-with-caller-table-must-not-be-public.md b/microsoft/knowledge/security/recordref-open-with-caller-table-must-not-be-public.md new file mode 100644 index 0000000..c84e15a --- /dev/null +++ b/microsoft/knowledge/security/recordref-open-with-caller-table-must-not-be-public.md @@ -0,0 +1,22 @@ +--- +bc-version: [all] +domain: security +keywords: [recordref, open, public, system-table, scope-onprem, confused-deputy, saas] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Procedures that RecordRef.Open a caller-provided table must not be public + +## Description + +When a codeunit holds permission to system tables — directly, via a permission set granted at install, or via `[InherentPermissions]` — and exposes a public procedure that accepts a table number (or a `RecordId`, from which the table number is derived) and calls `RecordRef.Open` on it, the procedure becomes a confused deputy. Any other extension on the same tenant can invoke the procedure with the table number of a system table the calling extension does not own permissions for and obtain access to its rows through the wrapper. This is especially acute in SaaS: an on-premises-style extension that holds broad permissions can be exploited by a co-tenant extension that calls its public surface. + +## Best Practice + +Mark such procedures `local` (callable only inside the containing object), `internal` (callable only inside the owning extension), or `[Scope('OnPrem')]` (not callable from SaaS extensions). If the procedure must be public, validate the table number against an allow-list before `RecordRef.Open` — `if not IsAllowedTable(RecId.TableNo) then Error(...)` — so the caller cannot specify an arbitrary table. See sample: `recordref-open-with-caller-table-must-not-be-public.good.al`. + +## Anti Pattern + +`procedure ArchiveRecord(RecId: RecordId)` (public by default) whose body calls `RecRef.Open(RecId.TableNo)` and then reads, modifies, or deletes the record. Reviewers should flag any procedure that is public (no `local`/`internal`/`[Scope('OnPrem')]`), takes a `RecordId`, `Integer` table number, or `Variant` as a parameter, and calls `RecordRef.Open` with that parameter — unless an allow-list check on the table number precedes the open. See sample: `recordref-open-with-caller-table-must-not-be-public.bad.al`. diff --git a/microsoft/knowledge/security/require-https-for-external-calls.bad.al b/microsoft/knowledge/security/require-https-for-external-calls.bad.al deleted file mode 100644 index 4edf291..0000000 --- a/microsoft/knowledge/security/require-https-for-external-calls.bad.al +++ /dev/null @@ -1,10 +0,0 @@ -codeunit 50219 "Sec Sample Https Bad" -{ - procedure CallExternal() - var - Client: HttpClient; - Response: HttpResponseMessage; - begin - Client.Get('http://api.example.com/data', Response); - end; -} diff --git a/microsoft/knowledge/security/require-https-for-external-calls.good.al b/microsoft/knowledge/security/require-https-for-external-calls.good.al deleted file mode 100644 index 6ae7639..0000000 --- a/microsoft/knowledge/security/require-https-for-external-calls.good.al +++ /dev/null @@ -1,12 +0,0 @@ -codeunit 50218 "Sec Sample Https Good" -{ - procedure CallExternal(Endpoint: Text) - var - Client: HttpClient; - Response: HttpResponseMessage; - begin - if not Endpoint.StartsWith('https://') then - Error('Only HTTPS endpoints are allowed.'); - Client.Get(Endpoint, Response); - end; -} diff --git a/microsoft/knowledge/security/require-https-for-external-calls.md b/microsoft/knowledge/security/require-https-for-external-calls.md deleted file mode 100644 index a026015..0000000 --- a/microsoft/knowledge/security/require-https-for-external-calls.md +++ /dev/null @@ -1,29 +0,0 @@ ---- -bc-version: [26..28] -domain: security -keywords: [https, httpclient, tls, plaintext] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Require HTTPS for external calls - -> **Seed article.** Converted from an existing security-review prompt to bootstrap the BCQuality security corpus. Domain stewards should expand, restructure, and refine as needed. - -## Description - -HttpClient can issue requests over plaintext HTTP as easily as over HTTPS. A request sent over http:// is transmitted unencrypted, exposing the full URL (including query string), the request headers (including Authorization), and the bodies of both request and response to any on-path observer. This holds even when the payload itself is not marked sensitive — request signatures and session tokens are routinely captured and replayed. - -## Best Practice - -Call external services exclusively over https://. When the destination is configurable, validate at runtime that the scheme is https before issuing the request, and fail closed with a clear (non-disclosing) error otherwise. - -See sample: `require-https-for-external-calls.good.al`. - -## Anti Pattern - -Issuing HttpClient.Get('http://...'), or accepting an arbitrary user-supplied URL and passing it straight to HttpClient without scheme validation. - -See sample: `require-https-for-external-calls.bad.al`. - diff --git a/microsoft/knowledge/security/secretstrsubstno-for-composing-secrets.bad.al b/microsoft/knowledge/security/secretstrsubstno-for-composing-secrets.bad.al new file mode 100644 index 0000000..84dda45 --- /dev/null +++ b/microsoft/knowledge/security/secretstrsubstno-for-composing-secrets.bad.al @@ -0,0 +1,12 @@ +codeunit 50212 "Sec Sample SecretSubst Bad" +{ + procedure BuildAuthHeader(Token: SecretText): Text + begin + exit(StrSubstNo('Bearer %1', Token.Unwrap())); + end; + + procedure BuildSecretUri(BaseUrl: Text; ApiKey: SecretText): Text + begin + exit(BaseUrl + '?key=' + ApiKey.Unwrap()); + end; +} diff --git a/microsoft/knowledge/security/secretstrsubstno-for-composing-secrets.good.al b/microsoft/knowledge/security/secretstrsubstno-for-composing-secrets.good.al new file mode 100644 index 0000000..f550025 --- /dev/null +++ b/microsoft/knowledge/security/secretstrsubstno-for-composing-secrets.good.al @@ -0,0 +1,12 @@ +codeunit 50211 "Sec Sample SecretSubst Good" +{ + procedure BuildAuthHeader(Token: SecretText): SecretText + begin + exit(SecretStrSubstNo('Bearer %1', Token)); + end; + + procedure BuildSecretUri(BaseUrl: Text; ApiKey: SecretText): SecretText + begin + exit(SecretStrSubstNo('%1?key=%2', BaseUrl, ApiKey)); + end; +} diff --git a/microsoft/knowledge/security/secretstrsubstno-for-composing-secrets.md b/microsoft/knowledge/security/secretstrsubstno-for-composing-secrets.md new file mode 100644 index 0000000..6e315f7 --- /dev/null +++ b/microsoft/knowledge/security/secretstrsubstno-for-composing-secrets.md @@ -0,0 +1,22 @@ +--- +bc-version: [all] +domain: security +keywords: [secretstrsubstno, secrettext, strsubstno, format, compose] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Use SecretStrSubstNo to compose strings that contain secrets + +## Description + +`SecretStrSubstNo` is the secret-preserving counterpart of `StrSubstNo`. It accepts a format string and arguments (any of which may be `SecretText`) and returns a `SecretText` — the substitution happens without ever materializing the result as plain `Text`. It is the right tool whenever a secret needs to be embedded in a larger string: an `Authorization: Bearer ` header value, a URI that includes an API key as a query parameter, or any other interpolation that combines a `SecretText` with surrounding context. + +## Best Practice + +Compose every secret-bearing string through `SecretStrSubstNo` and keep the result as `SecretText` end-to-end. Pass the result to the `SecretText` overload of the consumer — `HttpClient.SetSecretRequestUri`, `HttpHeaders.Add`, or `HttpContent.WriteFrom`. See sample: `secretstrsubstno-for-composing-secrets.good.al`. + +## Anti Pattern + +Calling `StrSubstNo('Bearer %1', Token.Unwrap())` to build the header value, or concatenating `'Bearer ' + Token.Unwrap()`. Both produce a plain `Text` containing the secret, which is then visible in the debugger and in any subsequent log or trace. Reviewers should flag any `Unwrap()` whose result is fed into `StrSubstNo` or used in `+` concatenation — `SecretStrSubstNo` removes the need for either. See sample: `secretstrsubstno-for-composing-secrets.bad.al`. diff --git a/microsoft/knowledge/security/secrettext-for-credentials.bad.al b/microsoft/knowledge/security/secrettext-for-credentials.bad.al new file mode 100644 index 0000000..5b5ac23 --- /dev/null +++ b/microsoft/knowledge/security/secrettext-for-credentials.bad.al @@ -0,0 +1,22 @@ +codeunit 50208 "Sec Sample SecretText Bad" +{ + procedure CallExternalApi() + var + ApiKey: Text; + BearerToken: Text; + HttpClient: HttpClient; + Response: HttpResponseMessage; + Headers: HttpHeaders; + begin + ApiKey := GetApiKey(); + BearerToken := GetAccessToken(); + Headers := HttpClient.DefaultRequestHeaders(); + Headers.Add('Authorization', 'Bearer ' + BearerToken); + Headers.Add('X-Api-Key', ApiKey); + HttpClient.Get('https://api.example.com/data', Response); + end; + + local procedure GetApiKey(): Text begin end; + + local procedure GetAccessToken(): Text begin end; +} diff --git a/microsoft/knowledge/security/secrettext-for-credentials.good.al b/microsoft/knowledge/security/secrettext-for-credentials.good.al new file mode 100644 index 0000000..d98f127 --- /dev/null +++ b/microsoft/knowledge/security/secrettext-for-credentials.good.al @@ -0,0 +1,16 @@ +codeunit 50207 "Sec Sample SecretText Good" +{ + procedure CallExternalApi() + var + ApiKey: SecretText; + HttpClient: HttpClient; + Response: HttpResponseMessage; + Headers: HttpHeaders; + begin + if IsolatedStorage.Contains('ApiKey', DataScope::Module) then + IsolatedStorage.Get('ApiKey', DataScope::Module, ApiKey); + Headers := HttpClient.DefaultRequestHeaders(); + Headers.Add('X-Api-Key', ApiKey); + HttpClient.Get('https://api.example.com/data', Response); + end; +} diff --git a/microsoft/knowledge/security/secrettext-for-credentials.md b/microsoft/knowledge/security/secrettext-for-credentials.md new file mode 100644 index 0000000..17fec22 --- /dev/null +++ b/microsoft/knowledge/security/secrettext-for-credentials.md @@ -0,0 +1,22 @@ +--- +bc-version: [all] +domain: security +keywords: [secrettext, credentials, api-key, token, debugger, unwrap] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Use SecretText for credentials, API keys, and tokens + +## Description + +`SecretText` is the AL data type for values that should never appear in a debugger session, in a log, or in a variable watch. The compiler enforces two guarantees: a string literal cannot be assigned directly to a `SecretText` variable, and a `SecretText` cannot be assigned back to a `Text` or `Code` without an explicit `Unwrap` call. Together these prevent the two common accidents — embedding a secret in source code, and quietly converting a secret to plain text where the debugger can read it. Use `SecretText` for parameters, return values, and local variables that carry API keys, tokens, passwords, connection strings, or any other value an attacker with debugger access should not see. + +## Best Practice + +Declare credential-carrying parameters and variables as `SecretText` from the call site that retrieves the secret all the way to the call site that consumes it (typically an `HttpClient` header or URI). Never round-trip through `Text` — every conversion is a potential exposure point. Retrieve secrets from `IsolatedStorage` with the `SecretText` overload of `Get` rather than the `Text` overload. See sample: `secrettext-for-credentials.good.al`. + +## Anti Pattern + +Holding a credential in a `Text` variable (`BearerToken: Text`), concatenating it into a header, then passing it to `HttpClient`. The token is visible in the debugger and in any error that prints the variable, and the compiler offers no help because the type was wrong from the start. Reviewers should flag any local or parameter named like a secret (`ApiKey`, `Token`, `Password`, `ClientSecret`) whose type is `Text` or `Code`. See sample: `secrettext-for-credentials.bad.al`. diff --git a/microsoft/knowledge/security/secrettext-with-httpclient.bad.al b/microsoft/knowledge/security/secrettext-with-httpclient.bad.al new file mode 100644 index 0000000..6ddb883 --- /dev/null +++ b/microsoft/knowledge/security/secrettext-with-httpclient.bad.al @@ -0,0 +1,23 @@ +codeunit 50210 "Sec Sample SecretHttp Bad" +{ + procedure CallApiWithSecretInUri(ApiKey: SecretText) + var + HttpClient: HttpClient; + Response: HttpResponseMessage; + RequestUri: Text; + begin + RequestUri := 'https://api.example.com/data?key=' + ApiKey.Unwrap(); + HttpClient.Get(RequestUri, Response); + end; + + procedure CallApiWithBearer(BearerToken: SecretText) + var + HttpClient: HttpClient; + Response: HttpResponseMessage; + Headers: HttpHeaders; + begin + Headers := HttpClient.DefaultRequestHeaders(); + Headers.Add('Authorization', 'Bearer ' + BearerToken.Unwrap()); + HttpClient.Get('https://api.example.com/data', Response); + end; +} diff --git a/microsoft/knowledge/security/secrettext-with-httpclient.good.al b/microsoft/knowledge/security/secrettext-with-httpclient.good.al new file mode 100644 index 0000000..50f0e31 --- /dev/null +++ b/microsoft/knowledge/security/secrettext-with-httpclient.good.al @@ -0,0 +1,28 @@ +codeunit 50209 "Sec Sample SecretHttp Good" +{ + procedure CallApiWithSecretUri(ApiKey: SecretText) + var + HttpClient: HttpClient; + Response: HttpResponseMessage; + SecretUri: SecretText; + begin + SecretUri := SecretStrSubstNo('https://api.example.com/data?key=%1', ApiKey); + HttpClient.SetSecretRequestUri(SecretUri); + HttpClient.Get('', Response); + end; + + procedure CallApiWithBearer(BearerToken: SecretText) + var + HttpClient: HttpClient; + Response: HttpResponseMessage; + Headers: HttpHeaders; + AuthHeader: SecretText; + begin + AuthHeader := SecretStrSubstNo('Bearer %1', BearerToken); + Headers := HttpClient.DefaultRequestHeaders(); + Headers.Add('Authorization', AuthHeader); + if not Headers.ContainsSecret('Authorization') then + Error('Authorization header missing'); + HttpClient.Get('https://api.example.com/data', Response); + end; +} diff --git a/microsoft/knowledge/security/secrettext-with-httpclient.md b/microsoft/knowledge/security/secrettext-with-httpclient.md new file mode 100644 index 0000000..f8be895 --- /dev/null +++ b/microsoft/knowledge/security/secrettext-with-httpclient.md @@ -0,0 +1,22 @@ +--- +bc-version: [all] +domain: security +keywords: [secrettext, httpclient, setsecretrequesturi, containssecret, headers, http] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Use the SecretText-aware HttpClient surface for secrets in requests + +## Description + +`HttpClient` and its companion types expose a parallel surface that accepts `SecretText` instead of `Text`, so that secret URIs, secret headers, and secret request bodies never round-trip through plain text. The key entry points are: `HttpClient.SetSecretRequestUri()` for URIs that contain secrets (the subsequent `Get`/`Post` is then called with an empty string); `HttpHeaders.Add()` overload that accepts a `SecretText` value for authorization headers; `HttpHeaders.ContainsSecret()` to test whether a secret header is present (the plain `Contains()` returns false for secret headers); `HttpContent.WriteFrom()` and `HttpContent.ReadAs()` overloads that accept and produce `SecretText` for request and response bodies that carry credentials. + +## Best Practice + +When the URI contains a secret query parameter, compose it as `SecretText` (see `secretstrsubstno-for-composing-secrets.md`), pass it to `SetSecretRequestUri`, and call `Get('', Response)` with an empty string as the URI argument. When the credential is an authorization header, build the header value as `SecretText` and pass it to `Headers.Add`. Use `ContainsSecret` rather than `Contains` to check for the presence of a secret header. See sample: `secrettext-with-httpclient.good.al`. + +## Anti Pattern + +Calling `ApiKey.Unwrap()` to build a URI or header string and passing the resulting `Text` to `HttpClient.Get` or `Headers.Add`. The unwrapped secret is now visible in the debugger, in any HTTP trace that captures the request URI, and in any error that includes the URI. Reviewers should flag any `Unwrap()` call whose result flows into an `HttpClient` argument; the `SecretText` overload exists precisely so the unwrap is not needed. See sample: `secrettext-with-httpclient.bad.al`. diff --git a/microsoft/knowledge/security/set-timeouts-for-external-calls.bad.al b/microsoft/knowledge/security/set-timeouts-for-external-calls.bad.al deleted file mode 100644 index 2068c1f..0000000 --- a/microsoft/knowledge/security/set-timeouts-for-external-calls.bad.al +++ /dev/null @@ -1,11 +0,0 @@ -codeunit 50221 "Sec Sample Timeout Bad" -{ - procedure CallExternal() - var - Client: HttpClient; - Response: HttpResponseMessage; - begin - // No Timeout set; a hung endpoint stalls the caller. - Client.Get('https://api.example.com/data', Response); - end; -} diff --git a/microsoft/knowledge/security/set-timeouts-for-external-calls.good.al b/microsoft/knowledge/security/set-timeouts-for-external-calls.good.al deleted file mode 100644 index ad9910c..0000000 --- a/microsoft/knowledge/security/set-timeouts-for-external-calls.good.al +++ /dev/null @@ -1,12 +0,0 @@ -codeunit 50220 "Sec Sample Timeout Good" -{ - procedure CallExternal() - var - Client: HttpClient; - Response: HttpResponseMessage; - begin - Client.Timeout := 10000; // 10 seconds - if not Client.Get('https://api.example.com/data', Response) then - Error('External service is unavailable.'); - end; -} diff --git a/microsoft/knowledge/security/set-timeouts-for-external-calls.md b/microsoft/knowledge/security/set-timeouts-for-external-calls.md deleted file mode 100644 index 28d83b0..0000000 --- a/microsoft/knowledge/security/set-timeouts-for-external-calls.md +++ /dev/null @@ -1,29 +0,0 @@ ---- -bc-version: [26..28] -domain: security -keywords: [timeout, httpclient, availability, dos] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Set timeouts for external calls - -> **Seed article.** Converted from an existing security-review prompt to bootstrap the BCQuality security corpus. Domain stewards should expand, restructure, and refine as needed. - -## Description - -An HttpClient with no explicit timeout relies on defaults that may be long enough for a hung or slow endpoint to block a user session or a background task for minutes. A dependency that degrades therefore degrades the caller, and an intentionally slow endpoint is a cheap denial-of-service vector against the extension. - -## Best Practice - -Set HttpClient.Timeout to a bounded value (seconds, not minutes) that reflects the SLA of the dependency. Handle the timeout error without leaking endpoint details to end users (see avoid-sensitive-data-in-error-messages). - -See sample: `set-timeouts-for-external-calls.good.al`. - -## Anti Pattern - -Issuing HttpClient requests without setting Timeout and without a timeout-handling branch. A slow dependency now has an unbounded blast radius inside the extension. - -See sample: `set-timeouts-for-external-calls.bad.al`. - diff --git a/microsoft/knowledge/security/use-indirect-permissions-for-elevated-access.bad.al b/microsoft/knowledge/security/use-indirect-permissions-for-elevated-access.bad.al deleted file mode 100644 index d22f177..0000000 --- a/microsoft/knowledge/security/use-indirect-permissions-for-elevated-access.bad.al +++ /dev/null @@ -1,7 +0,0 @@ -permissionset 50203 "Sec Sample Direct Write" -{ - Assignable = true; - Caption = 'Direct write granted to every caller (sample anti-pattern)'; - Permissions = - tabledata "Sales Header" = RM; -} diff --git a/microsoft/knowledge/security/use-indirect-permissions-for-elevated-access.good.al b/microsoft/knowledge/security/use-indirect-permissions-for-elevated-access.good.al deleted file mode 100644 index c2d3e20..0000000 --- a/microsoft/knowledge/security/use-indirect-permissions-for-elevated-access.good.al +++ /dev/null @@ -1,34 +0,0 @@ -permissionset 50202 "Sec Sample Elevated Write" -{ - Assignable = false; - Caption = 'Elevated write via helper (sample)'; - // Callers hold R directly; the helper codeunit assumes this set and - // performs the Modify via indirect permission. - Permissions = - tabledata "Sales Header" = Rmi; -} - -codeunit 50231 "Sec Sample Elevated Helper" -{ - Access = Public; - Permissions = tabledata "Sales Header" = Rmi; - - procedure SetExternalDocumentNo(SalesDocType: Enum "Sales Document Type"; SalesDocNo: Code[20]; NewExternalDocNo: Code[35]) - var - SalesHeader: Record "Sales Header"; - begin - ValidateCaller(); - if NewExternalDocNo = '' then - Error('External document number must be provided.'); - if not SalesHeader.Get(SalesDocType, SalesDocNo) then - Error('Sales document not found.'); - SalesHeader."External Document No." := NewExternalDocNo; - SalesHeader.Modify(true); - end; - - local procedure ValidateCaller() - begin - // Verify the caller is permitted to perform this elevated write - // (role check, setup flag, approvals, etc.). - end; -} diff --git a/microsoft/knowledge/security/use-indirect-permissions-for-elevated-access.md b/microsoft/knowledge/security/use-indirect-permissions-for-elevated-access.md deleted file mode 100644 index ecf648b..0000000 --- a/microsoft/knowledge/security/use-indirect-permissions-for-elevated-access.md +++ /dev/null @@ -1,29 +0,0 @@ ---- -bc-version: [26..28] -domain: security -keywords: [indirect-permission, elevation, permissionset] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Use indirect permissions for elevated access - -> **Seed article.** Converted from an existing security-review prompt to bootstrap the BCQuality security corpus. Domain stewards should expand, restructure, and refine as needed. - -## Description - -Indirect permissions (ri, ii, mi, di) let a procedure perform an operation against tabledata the caller does not have direct rights to, provided the caller is authorized to invoke the procedure. They are the supported mechanism for elevation: instead of widening every caller's direct rights to M or D, the sensitive operation lives in a codeunit that holds the indirect right and validates its callers. - -## Best Practice - -Where a module exposes a controlled write or delete against a sensitive table, grant the codeunit (or the helper permission set it assumes) the indirect permission (mi, di) it requires, keep direct permissions minimal, and document why the elevation is justified. The helper MUST validate its inputs and the caller's identity before performing the elevated work. - -See sample: `use-indirect-permissions-for-elevated-access.good.al`. - -## Anti Pattern - -Granting direct M or D on a sensitive tabledata to every role that might invoke a helper, because authoring an indirect-permission codeunit was inconvenient. Every caller now has the elevated right for every code path, not just the one the helper implements. - -See sample: `use-indirect-permissions-for-elevated-access.bad.al`. - diff --git a/microsoft/knowledge/security/use-inherent-permissions-to-grant-minimal-access.bad.al b/microsoft/knowledge/security/use-inherent-permissions-to-grant-minimal-access.bad.al deleted file mode 100644 index 25a6740..0000000 --- a/microsoft/knowledge/security/use-inherent-permissions-to-grant-minimal-access.bad.al +++ /dev/null @@ -1,13 +0,0 @@ -codeunit 50205 "Sec Sample Inherent Bad" -{ - // No InherentPermissions attribute: every caller must hold - // tabledata "Sec Sample Lookup" = R just to look up a name. - procedure GetLookupName(LookupCode: Code[20]): Text[100] - var - Lookup: Record "Sec Sample Lookup"; - begin - if Lookup.Get(LookupCode) then - exit(Lookup.Name); - exit(''); - end; -} diff --git a/microsoft/knowledge/security/use-inherent-permissions-to-grant-minimal-access.good.al b/microsoft/knowledge/security/use-inherent-permissions-to-grant-minimal-access.good.al deleted file mode 100644 index a1e5898..0000000 --- a/microsoft/knowledge/security/use-inherent-permissions-to-grant-minimal-access.good.al +++ /dev/null @@ -1,28 +0,0 @@ -table 50230 "Sec Sample Lookup" -{ - DataClassification = SystemMetadata; - - fields - { - field(1; "Code"; Code[20]) { } - field(2; "Name"; Text[100]) { } - } - - keys - { - key(PK; "Code") { Clustered = true; } - } -} - -codeunit 50204 "Sec Sample Inherent Good" -{ - [InherentPermissions(PermissionObjectType::TableData, Database::"Sec Sample Lookup", 'r')] - procedure GetLookupName(LookupCode: Code[20]): Text[100] - var - Lookup: Record "Sec Sample Lookup"; - begin - if Lookup.Get(LookupCode) then - exit(Lookup.Name); - exit(''); - end; -} diff --git a/microsoft/knowledge/security/use-inherent-permissions-to-grant-minimal-access.md b/microsoft/knowledge/security/use-inherent-permissions-to-grant-minimal-access.md deleted file mode 100644 index d305791..0000000 --- a/microsoft/knowledge/security/use-inherent-permissions-to-grant-minimal-access.md +++ /dev/null @@ -1,29 +0,0 @@ ---- -bc-version: [26..28] -domain: security -keywords: [inherentpermissions, attribute, least-privilege] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Use InherentPermissions to grant minimal access - -> **Seed article.** Converted from an existing security-review prompt to bootstrap the BCQuality security corpus. Domain stewards should expand, restructure, and refine as needed. - -## Description - -The InherentPermissions attribute attaches a minimum access grant to a procedure. Callers can invoke the procedure without holding the underlying tabledata right, because the attribute supplies exactly the right required by the procedure body and nothing more. InherentPermissions currently targets only objects owned by the same extension as the annotated procedure; it cannot be used to grant access to tables in other extensions or in the base application. - -## Best Practice - -Annotate read-only helper procedures with InherentPermissions specifying only the tables and access letters the body uses (typically 'r'). Callers do not need direct read rights on the underlying extension-owned table, so the calling role can be narrower. This is the narrowest of the elevation options and is appropriate for read-only lookup helpers. - -See sample: `use-inherent-permissions-to-grant-minimal-access.good.al`. - -## Anti Pattern - -A helper that reads a single lookup value but forces every calling role to hold tabledata read rights, because the helper does not declare its own inherent permissions. The broad read right then applies to every other code path that role can reach, not just the helper. - -See sample: `use-inherent-permissions-to-grant-minimal-access.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 deleted file mode 100644 index 7cf0cfa..0000000 --- a/microsoft/knowledge/security/use-isolated-storage-for-module-and-company-secrets.bad.al +++ /dev/null @@ -1,17 +0,0 @@ -codeunit 50209 "Sec Sample IsolatedStorage Bad" -{ - procedure StoreApiKey(NewKey: Text) - begin - // Plaintext write to IsolatedStorage is not encrypted at rest. - IsolatedStorage.Set('ApiKey', NewKey, DataScope::Module); - end; - - procedure GetApiKey(): Text - var - ApiKey: Text; - begin - if IsolatedStorage.Get('ApiKey', DataScope::Module, ApiKey) then - exit(ApiKey); - exit(''); - end; -} 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 deleted file mode 100644 index a22b568..0000000 --- a/microsoft/knowledge/security/use-isolated-storage-for-module-and-company-secrets.good.al +++ /dev/null @@ -1,14 +0,0 @@ -codeunit 50208 "Sec Sample IsolatedStorage Good" -{ - procedure StoreApiKey(NewKey: SecretText) - begin - IsolatedStorage.SetEncrypted('ApiKey', NewKey, DataScope::Module); - end; - - procedure TryGetApiKey(var ApiKey: SecretText): Boolean - begin - if IsolatedStorage.Contains('ApiKey', DataScope::Module) then - exit(IsolatedStorage.Get('ApiKey', DataScope::Module, ApiKey)); - exit(false); - end; -} 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 deleted file mode 100644 index 78b46dc..0000000 --- a/microsoft/knowledge/security/use-isolated-storage-for-module-and-company-secrets.md +++ /dev/null @@ -1,29 +0,0 @@ ---- -bc-version: [26..28] -domain: security -keywords: [isolatedstorage, encryption, datascope, secrets] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Use IsolatedStorage for module and company secrets - -> **Seed article.** Converted from an existing security-review prompt to bootstrap the BCQuality security corpus. Domain stewards should expand, restructure, and refine as needed. - -## Description - -IsolatedStorage is a per-extension, per-tenant key-value store. DataScope::Module isolates values to the extension across the tenant; DataScope::Company scopes them to a single company within the tenant. The SetEncrypted method stores the value encrypted at rest; Set stores it in plaintext. SetEncrypted accepts inputs up to 215 characters (special characters may consume more space). - -## 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. - -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. - -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 deleted file mode 100644 index 2fc4321..0000000 --- a/microsoft/knowledge/security/use-nondebuggable-when-parsing-secrets.bad.al +++ /dev/null @@ -1,16 +0,0 @@ -codeunit 50217 "Sec Sample NonDebuggable Bad" -{ - // Missing [NonDebuggable]: ResponseText and the extracted token are - // inspectable in the debugger and in snapshot debug sessions. - procedure ParseSessionToken(Response: HttpResponseMessage; var SessionToken: SecretText) - var - ResponseText: Text; - JObject: JsonObject; - JToken: JsonToken; - begin - Response.Content.ReadAs(ResponseText); - JObject.ReadFrom(ResponseText); - JObject.Get('access_token', JToken); - SessionToken := JToken.AsValue().AsText(); - 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 deleted file mode 100644 index d055890..0000000 --- a/microsoft/knowledge/security/use-nondebuggable-when-parsing-secrets.good.al +++ /dev/null @@ -1,15 +0,0 @@ -codeunit 50216 "Sec Sample NonDebuggable Good" -{ - [NonDebuggable] - procedure ParseSessionToken(Response: HttpResponseMessage; var SessionToken: SecretText) - var - ResponseText: Text; - JObject: JsonObject; - JToken: JsonToken; - begin - Response.Content.ReadAs(ResponseText); - JObject.ReadFrom(ResponseText); - JObject.Get('access_token', JToken); - SessionToken := JToken.AsValue().AsText(); - end; -} diff --git a/microsoft/knowledge/security/use-nondebuggable-when-parsing-secrets.md b/microsoft/knowledge/security/use-nondebuggable-when-parsing-secrets.md deleted file mode 100644 index 4389394..0000000 --- a/microsoft/knowledge/security/use-nondebuggable-when-parsing-secrets.md +++ /dev/null @@ -1,29 +0,0 @@ ---- -bc-version: [26..28] -domain: security -keywords: [nondebuggable, secrettext, attribute, parse] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Use NonDebuggable when parsing secrets - -> **Seed article.** Converted from an existing security-review prompt to bootstrap the BCQuality security corpus. Domain stewards should expand, restructure, and refine as needed. - -## 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. - -## 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. - -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. - -See sample: `use-nondebuggable-when-parsing-secrets.bad.al`. - diff --git a/microsoft/knowledge/security/use-secrettext-for-credentials.bad.al b/microsoft/knowledge/security/use-secrettext-for-credentials.bad.al deleted file mode 100644 index cef9e4a..0000000 --- a/microsoft/knowledge/security/use-secrettext-for-credentials.bad.al +++ /dev/null @@ -1,13 +0,0 @@ -codeunit 50211 "Sec Sample SecretText Bad" -{ - procedure SendAuthenticatedRequest(BearerToken: Text) - var - Client: HttpClient; - Response: HttpResponseMessage; - AuthValue: Text; - begin - AuthValue := 'Bearer ' + BearerToken; - Client.DefaultRequestHeaders.Add('Authorization', AuthValue); - Client.Get('https://api.example.com/data', Response); - end; -} diff --git a/microsoft/knowledge/security/use-secrettext-for-credentials.good.al b/microsoft/knowledge/security/use-secrettext-for-credentials.good.al deleted file mode 100644 index 269d50f..0000000 --- a/microsoft/knowledge/security/use-secrettext-for-credentials.good.al +++ /dev/null @@ -1,14 +0,0 @@ -codeunit 50210 "Sec Sample SecretText Good" -{ - procedure SendAuthenticatedRequest(BearerToken: SecretText) - var - Client: HttpClient; - Headers: HttpHeaders; - Response: HttpResponseMessage; - AuthValue: SecretText; - begin - AuthValue := SecretStrSubstNo('Bearer %1', BearerToken); - Client.DefaultRequestHeaders.Add('Authorization', AuthValue); - Client.Get('https://api.example.com/data', Response); - end; -} diff --git a/microsoft/knowledge/security/use-secrettext-for-credentials.md b/microsoft/knowledge/security/use-secrettext-for-credentials.md deleted file mode 100644 index 991f2d0..0000000 --- a/microsoft/knowledge/security/use-secrettext-for-credentials.md +++ /dev/null @@ -1,29 +0,0 @@ ---- -bc-version: [26..28] -domain: security -keywords: [secrettext, credentials, debugger, type] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Use SecretText for credentials - -> **Seed article.** Converted from an existing security-review prompt to bootstrap the BCQuality security corpus. Domain stewards should expand, restructure, and refine as needed. - -## Description - -SecretText is a compile-time-checked AL type for credentials, API keys, tokens, and similar sensitive values. The compiler rejects literal assignments to SecretText and blocks implicit conversion back to Text or Code, which prevents many accidental disclosures via logs, errors, and the debugger (regular and snapshot). A SecretText value remains opaque throughout its lifetime. - -## Best Practice - -Type every credential-carrying variable, procedure parameter, and return as SecretText. Compose values with SecretStrSubstNo (see compose-secrets-with-secretstrsubstno). For HttpClient integration, see use-secrettext-with-httpclient. When a secret must be extracted from a Text source, contain that conversion in a NonDebuggable procedure (see use-nondebuggable-when-parsing-secrets). - -See sample: `use-secrettext-for-credentials.good.al`. - -## Anti Pattern - -Passing credentials around as Text or Code parameters. Every such variable is visible in the debugger and may be captured by error handlers, logs, and telemetry that treat Text as non-sensitive. - -See sample: `use-secrettext-for-credentials.bad.al`. - diff --git a/microsoft/knowledge/security/use-secrettext-with-httpclient.bad.al b/microsoft/knowledge/security/use-secrettext-with-httpclient.bad.al deleted file mode 100644 index 4d8dd89..0000000 --- a/microsoft/knowledge/security/use-secrettext-with-httpclient.bad.al +++ /dev/null @@ -1,12 +0,0 @@ -codeunit 50213 "Sec Sample SecretHttpClient Bad" -{ - procedure Call(ApiKey: Text) - var - Client: HttpClient; - Response: HttpResponseMessage; - FullUrl: Text; - begin - FullUrl := 'https://api.example.com/v1?key=' + ApiKey; - Client.Get(FullUrl, Response); - end; -} diff --git a/microsoft/knowledge/security/use-secrettext-with-httpclient.good.al b/microsoft/knowledge/security/use-secrettext-with-httpclient.good.al deleted file mode 100644 index 2ea9a73..0000000 --- a/microsoft/knowledge/security/use-secrettext-with-httpclient.good.al +++ /dev/null @@ -1,15 +0,0 @@ -codeunit 50212 "Sec Sample SecretHttpClient Good" -{ - procedure Call(ApiKey: SecretText) - var - Client: HttpClient; - Request: HttpRequestMessage; - Response: HttpResponseMessage; - SecretUri: SecretText; - begin - SecretUri := SecretStrSubstNo('https://api.example.com/v1?key=%1', ApiKey); - Request.SetSecretRequestUri(SecretUri); - Request.Method('GET'); - Client.Send(Request, Response); - end; -} diff --git a/microsoft/knowledge/security/use-secrettext-with-httpclient.md b/microsoft/knowledge/security/use-secrettext-with-httpclient.md deleted file mode 100644 index 2acb268..0000000 --- a/microsoft/knowledge/security/use-secrettext-with-httpclient.md +++ /dev/null @@ -1,29 +0,0 @@ ---- -bc-version: [26..28] -domain: security -keywords: [httpclient, secrettext, headers, uri] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Use SecretText with HttpClient - -> **Seed article.** Converted from an existing security-review prompt to bootstrap the BCQuality security corpus. Domain stewards should expand, restructure, and refine as needed. - -## Description - -HttpRequestMessage, HttpHeaders, and HttpContent expose SecretText overloads so credentials never have to be converted back to Text to be sent. Key APIs: HttpRequestMessage.SetSecretRequestUri (for URIs containing secrets), HttpHeaders.Add(name, SecretText) for authorization headers, HttpHeaders.ContainsSecret to probe secret-valued headers, HttpContent.WriteFrom(SecretText) for request bodies, and HttpContent.ReadAs(SecretText) to pull response bodies into a secret destination. - -## Best Practice - -Use HttpRequestMessage.SetSecretRequestUri when any URI component is sensitive (for example, a per-call API key in the path or query), and send the request with HttpClient.Send. Add Authorization headers as SecretText. Check for the presence of a secret header with ContainsSecret, not Contains. - -See sample: `use-secrettext-with-httpclient.good.al`. - -## Anti Pattern - -Materializing a URI or header value as Text to 'just get it to compile' — for example, StrSubstNo into a Text and then HttpClient.Get(FullUrl, Response). The resulting Text is visible in debuggers, and the URL is typically captured by platform-level logging the extension does not control. - -See sample: `use-secrettext-with-httpclient.bad.al`. - diff --git a/microsoft/knowledge/security/validate-user-configurable-urls.bad.al b/microsoft/knowledge/security/validate-user-configurable-urls.bad.al new file mode 100644 index 0000000..a5b0658 --- /dev/null +++ b/microsoft/knowledge/security/validate-user-configurable-urls.bad.al @@ -0,0 +1,20 @@ +codeunit 50222 "Sec Sample UrlValidation Bad" +{ + procedure SyncWithExternalService(ServiceUrl: Text) + var + HttpClient: HttpClient; + Response: HttpResponseMessage; + begin + HttpClient.Get(ServiceUrl, Response); + end; + + procedure SendWebhookNotification(CallbackUrl: Text; Payload: Text) + var + HttpClient: HttpClient; + Content: HttpContent; + Response: HttpResponseMessage; + begin + Content.WriteFrom(Payload); + HttpClient.Post(CallbackUrl, Content, Response); + end; +} diff --git a/microsoft/knowledge/security/validate-user-configurable-urls.good.al b/microsoft/knowledge/security/validate-user-configurable-urls.good.al new file mode 100644 index 0000000..0925b14 --- /dev/null +++ b/microsoft/knowledge/security/validate-user-configurable-urls.good.al @@ -0,0 +1,24 @@ +codeunit 50221 "Sec Sample UrlValidation Good" +{ + procedure SyncWithExternalService(ServiceUrl: Text) + var + HttpClient: HttpClient; + Response: HttpResponseMessage; + Uri: Codeunit Uri; + begin + if not Uri.AreURIsHaveSameHost(ServiceUrl, 'https://api.contoso.com') then + Error('Service URL must point to api.contoso.com'); + HttpClient.Get(ServiceUrl, Response); + end; + + procedure SyncWithShopify(ShopUrl: Text) + var + HttpClient: HttpClient; + Response: HttpResponseMessage; + Uri: Codeunit Uri; + begin + if not Uri.IsValidURIPattern(ShopUrl, 'https://*.myshopify.com/*') then + Error('Shop URL must match the Shopify pattern'); + HttpClient.Get(ShopUrl + '/admin/api/2024-01/orders.json', Response); + end; +} diff --git a/microsoft/knowledge/security/validate-user-configurable-urls.md b/microsoft/knowledge/security/validate-user-configurable-urls.md new file mode 100644 index 0000000..06cc31d --- /dev/null +++ b/microsoft/knowledge/security/validate-user-configurable-urls.md @@ -0,0 +1,22 @@ +--- +bc-version: [all] +domain: security +keywords: [ssrf, uri, url-validation, areurishavesamehost, isvaliduripattern, httpclient] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Validate URLs that come from table fields before calling them + +## Description + +A URL stored in a table field is user-configurable: anyone with write access to the row can change it. If that URL is then used as the target of an `HttpClient.Get`/`Post`, the extension becomes a server-side request forgery (SSRF) primitive — an attacker can redirect the call to an internal endpoint, to a metadata service, or to a malicious host that mirrors the legitimate API. The `Uri` codeunit from System Modules provides two validators built for this situation: `AreURIsHaveSameHost()` checks that two URLs share the same host (use when the hostname should not change — for example, the extension always talks to `api.contoso.com`). `IsValidURIPattern()` checks that a URL matches a wildcard pattern (use when the host varies but follows a predictable shape — for example `https://{store}.myshopify.com/...`). + +## Best Practice + +Before any `HttpClient` call whose URL came from a table field, call `Uri.AreURIsHaveSameHost(StoredUrl, ExpectedBaseUrl)` against a hard-coded expected base, or `Uri.IsValidURIPattern(StoredUrl, 'https://*.myshopify.com/*')` against a fixed pattern. Fail the call with an `Error` when the validator returns false. For webhook scenarios where the host is registered out-of-band, compare against the registered host stored alongside the URL. See sample: `validate-user-configurable-urls.good.al`. + +## Anti Pattern + +`HttpClient.Get(Setup."Service URL", Response)` or `HttpClient.Post(WebhookSetup."Callback URL", Content, Response)` with no validation step in between. The extension will dutifully send the request — and any sensitive payload — to whatever host the attacker put in the field. Reviewers should flag any `HttpClient` call whose first argument is a record field, an `OnValidate`-mutable field, or a value sourced from a table read, unless a `Uri.AreURIsHaveSameHost` or `Uri.IsValidURIPattern` check precedes it. See sample: `validate-user-configurable-urls.bad.al`. diff --git a/microsoft/knowledge/security/validatetablerelation-false-on-user-input.bad.al b/microsoft/knowledge/security/validatetablerelation-false-on-user-input.bad.al new file mode 100644 index 0000000..7029908 --- /dev/null +++ b/microsoft/knowledge/security/validatetablerelation-false-on-user-input.bad.al @@ -0,0 +1,11 @@ +tableextension 50225 "Sec Sample VTR Bad" extends Customer +{ + fields + { + field(50225; "Linked Customer No."; Code[20]) + { + TableRelation = Customer."No."; + ValidateTableRelation = false; + } + } +} diff --git a/microsoft/knowledge/security/validatetablerelation-false-on-user-input.good.al b/microsoft/knowledge/security/validatetablerelation-false-on-user-input.good.al new file mode 100644 index 0000000..9a49bc3 --- /dev/null +++ b/microsoft/knowledge/security/validatetablerelation-false-on-user-input.good.al @@ -0,0 +1,26 @@ +tableextension 50223 "Sec Sample VTR Good" extends Customer +{ + fields + { + field(50223; "System Batch ID"; Code[20]) + { + TableRelation = "Sales Header"."No."; + ValidateTableRelation = false; + Editable = false; + } + field(50224; "External Customer Ref"; Code[50]) + { + TableRelation = Customer."No."; + ValidateTableRelation = false; + trigger OnValidate() + var + Customer: Record Customer; + begin + if "External Customer Ref" = '' then + exit; + if not Customer.Get("External Customer Ref") then + Error('External customer reference %1 does not exist.', "External Customer Ref"); + end; + } + } +} diff --git a/microsoft/knowledge/security/validatetablerelation-false-on-user-input.md b/microsoft/knowledge/security/validatetablerelation-false-on-user-input.md new file mode 100644 index 0000000..275587c --- /dev/null +++ b/microsoft/knowledge/security/validatetablerelation-false-on-user-input.md @@ -0,0 +1,22 @@ +--- +bc-version: [all] +domain: security +keywords: [validatetablerelation, tablerelation, field, validation, input] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Do not set ValidateTableRelation = false on user-editable fields + +## Description + +`TableRelation` on a field declares that the field's value must exist in another table; the platform validates the value on entry and on `Validate`. Setting `ValidateTableRelation = false` keeps the relation as metadata (used by lookups, by Edit-in-Excel, by APIs) but turns off the runtime check. On a system-controlled, non-editable field that is populated only by the platform or by a posting routine, that is acceptable. On a user-editable field, it is dangerous: users can type any value, and downstream code that assumes the relation holds will read a `Customer` record that does not exist, post to an account that was deleted, or join against missing rows. + +## Best Practice + +Leave `ValidateTableRelation` at its default (true) on any field a user can edit. If there is a legitimate reason to turn it off — typically because the relation is not on the primary key, or because the relation is computed — replace it with an `OnValidate` trigger that performs the equivalent check (`if FieldValue <> '' then VerifyExternalReferenceExists(FieldValue)`). Combine `ValidateTableRelation = false` with `Editable = false` for system-controlled fields, so the metadata is correct and the field is unreachable from the UI. See sample: `validatetablerelation-false-on-user-input.good.al`. + +## Anti Pattern + +`ValidateTableRelation = false` on a user-facing input field (a `Customer No.` typed by a sales user) with no alternative validation. Reviewers should flag the combination of `ValidateTableRelation = false` and any of: `Editable = true` (the default), an `OnValidate` trigger that does not perform the relation check, or a page that surfaces the field as input. See sample: `validatetablerelation-false-on-user-input.bad.al`. diff --git a/microsoft/knowledge/style/abouttitle-abouttext-teaching-tips.bad.al b/microsoft/knowledge/style/abouttitle-abouttext-teaching-tips.bad.al new file mode 100644 index 0000000..a32072f --- /dev/null +++ b/microsoft/knowledge/style/abouttitle-abouttext-teaching-tips.bad.al @@ -0,0 +1,5 @@ +page 50258 "Sample AboutTitle Bad" +{ + PageType = List; + SourceTable = Customer; +} diff --git a/microsoft/knowledge/style/abouttitle-abouttext-teaching-tips.good.al b/microsoft/knowledge/style/abouttitle-abouttext-teaching-tips.good.al new file mode 100644 index 0000000..b497974 --- /dev/null +++ b/microsoft/knowledge/style/abouttitle-abouttext-teaching-tips.good.al @@ -0,0 +1,15 @@ +page 50256 "Sample AboutTitle Good List" +{ + PageType = List; + SourceTable = Customer; + AboutTitle = 'About customers'; + AboutText = 'Manage your customer database and track customer interactions. You can create new customers, update contact information, and view customer statistics.'; +} + +page 50257 "Sample AboutTitle Good Card" +{ + PageType = Card; + SourceTable = Customer; + AboutTitle = 'About customer details'; + AboutText = 'View and edit detailed customer information including contact details, payment terms, and billing preferences.'; +} diff --git a/microsoft/knowledge/style/abouttitle-abouttext-teaching-tips.md b/microsoft/knowledge/style/abouttitle-abouttext-teaching-tips.md new file mode 100644 index 0000000..f72b959 --- /dev/null +++ b/microsoft/knowledge/style/abouttitle-abouttext-teaching-tips.md @@ -0,0 +1,28 @@ +--- +bc-version: [all] +domain: style +keywords: [abouttitle, abouttext, teaching-tip, onboarding, page] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Use `AboutTitle` and `AboutText` to surface teaching tips on top-level pages + +## Description + +The `AboutTitle` and `AboutText` properties on a page render a teaching tip — an onboarding callout that appears the first time a user opens the page. They are supported on pages, individual page controls, FactBoxes, and report request pages. They are NOT supported on Role Centers or modal dialogs. The conventions: `AboutTitle` answers "what is this page about?" and uses the plural for list pages (`'About sales invoices'`) and the `[entity] details` form for card and document pages (`'About sales invoice details'`); `AboutText` answers "what can I do with this page?" in two or three short sentences. Both are translation-aware and surface to the end user verbatim. + +The reviewer signal is "this is a new top-level card or list page in an app whose sibling pages already define teaching tips" — when the surrounding app sets the precedent, a new page without `AboutTitle`/`AboutText` is an inconsistency worth flagging. + +## Best Practice + +Set `AboutTitle` and `AboutText` on every new top-level card, list, and document page in an app that already uses them. Keep `AboutText` to two or three short sentences. Describe what the page does, not the navigation steps to use it — teaching tips explain WHAT, not HOW. + +See sample: `abouttitle-abouttext-teaching-tips.good.al`. + +## Anti Pattern + +A new top-level page in an app whose siblings have `AboutTitle`/`AboutText`, but with no teaching tips defined. Equally wrong is filling `AboutText` with step-by-step instructions ("Click New, then enter…") — the property is for orientation, not procedural help. + +See sample: `abouttitle-abouttext-teaching-tips.bad.al`. diff --git a/microsoft/knowledge/style/api-page-camelcase-properties.bad.al b/microsoft/knowledge/style/api-page-camelcase-properties.bad.al new file mode 100644 index 0000000..47f2f11 --- /dev/null +++ b/microsoft/knowledge/style/api-page-camelcase-properties.bad.al @@ -0,0 +1,10 @@ +page 50219 "Sample API Camel Bad" +{ + PageType = API; + APIPublisher = 'Contoso-App'; + APIGroup = 'app_1'; + APIVersion = 'v2.0'; + EntityName = 'sales_order'; + EntitySetName = 'sales_orders'; + SourceTable = "Sales Header"; +} diff --git a/microsoft/knowledge/style/api-page-camelcase-properties.good.al b/microsoft/knowledge/style/api-page-camelcase-properties.good.al new file mode 100644 index 0000000..f460bb9 --- /dev/null +++ b/microsoft/knowledge/style/api-page-camelcase-properties.good.al @@ -0,0 +1,22 @@ +page 50218 "Sample API Camel Good" +{ + PageType = API; + APIPublisher = 'contoso'; + APIGroup = 'app1'; + APIVersion = 'v2.0'; + EntityName = 'customer'; + EntitySetName = 'customers'; + SourceTable = Customer; + DelayedInsert = true; + + layout + { + area(Content) + { + repeater(Group) + { + field(displayName; Rec.Name) { Caption = 'displayName'; } + } + } + } +} diff --git a/microsoft/knowledge/style/api-page-camelcase-properties.md b/microsoft/knowledge/style/api-page-camelcase-properties.md new file mode 100644 index 0000000..3baa009 --- /dev/null +++ b/microsoft/knowledge/style/api-page-camelcase-properties.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: style +keywords: [api-page, camelcase, apipublisher, apigroup, entityname, entitysetname] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# API pages use camelCase, alphanumeric-only values for API properties + +## Description + +API pages — pages declared with `PageType = API` — surface as OData/JSON endpoints. The strings that appear in the URL (`APIPublisher`, `APIGroup`, `EntityName`, `EntitySetName`) and the JSON payload field names follow different naming rules from the rest of AL. They must be camelCase and use only alphanumeric characters: no hyphens, no underscores, no spaces, no punctuation. `'Contoso-App'`, `'contoso_app'`, and `'contoso.app'` are all rejected. The same rule applies to page field names exposed via `Name = '…'` on API page controls — those names appear verbatim in the JSON keys. + +## Best Practice + +Pick camelCase identifiers up front: `APIPublisher = 'contoso'`, `APIGroup = 'app1'`, `EntityName = 'customer'`, field `Name = 'displayName'`. Keep them short — they end up in URL paths and JSON keys that every consumer types. + +See sample: `api-page-camelcase-properties.good.al`. + +## Anti Pattern + +`APIPublisher = 'Contoso-App'` (hyphen rejected, capitalization wrong for camelCase), `EntityName = 'sales_order'` (underscore rejected), or fields exposed with `Name = 'Display Name'` (space rejected). The compiler usually catches these, but the failure mode is opaque and the rename cost on a deployed API is high. + +See sample: `api-page-camelcase-properties.bad.al`. diff --git a/microsoft/knowledge/style/api-page-delayedinsert-true.bad.al b/microsoft/knowledge/style/api-page-delayedinsert-true.bad.al new file mode 100644 index 0000000..4cf3fe2 --- /dev/null +++ b/microsoft/knowledge/style/api-page-delayedinsert-true.bad.al @@ -0,0 +1,10 @@ +page 50227 "Sample DelayedInsert Bad" +{ + PageType = API; + APIPublisher = 'contoso'; + APIGroup = 'app1'; + APIVersion = 'v1.0'; + EntityName = 'customer'; + EntitySetName = 'customers'; + SourceTable = Customer; +} diff --git a/microsoft/knowledge/style/api-page-delayedinsert-true.good.al b/microsoft/knowledge/style/api-page-delayedinsert-true.good.al new file mode 100644 index 0000000..532d3cd --- /dev/null +++ b/microsoft/knowledge/style/api-page-delayedinsert-true.good.al @@ -0,0 +1,11 @@ +page 50226 "Sample DelayedInsert Good" +{ + PageType = API; + APIPublisher = 'contoso'; + APIGroup = 'app1'; + APIVersion = 'v1.0'; + EntityName = 'customer'; + EntitySetName = 'customers'; + SourceTable = Customer; + DelayedInsert = true; +} diff --git a/microsoft/knowledge/style/api-page-delayedinsert-true.md b/microsoft/knowledge/style/api-page-delayedinsert-true.md new file mode 100644 index 0000000..6045c2f --- /dev/null +++ b/microsoft/knowledge/style/api-page-delayedinsert-true.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: style +keywords: [api-page, delayedinsert, insert-trigger, validation] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Set `DelayedInsert = true` on API pages + +## Description + +On a normal page, `DelayedInsert = false` is the default: the record is inserted into the table as soon as the user enters the first field, and subsequent fields are written via `Modify` triggers. That model does not work for an API endpoint, where the consumer sends a complete JSON payload in a single request and expects exactly one `Insert` to fire with all fields already populated. `DelayedInsert = true` defers the insert until every field on the page has been assigned, so the `OnInsert` trigger runs once with the full record and `OnValidate` triggers on individual fields run in a predictable order. The convention is that API pages always set `DelayedInsert = true`. + +## Best Practice + +Declare `DelayedInsert = true` on every page with `PageType = API`. The setting plays well with `Modify(true)` and `Insert(true)` calls inside `OnInsert` and avoids the half-populated record states that otherwise reach validation logic. + +See sample: `api-page-delayedinsert-true.good.al`. + +## Anti Pattern + +Omitting `DelayedInsert` (which defaults to `false`) on an API page. Validation triggers fire on a partially populated record, mandatory-field errors come back to the caller for fields the JSON payload was about to supply, and the API surface produces failures that have no analogue in the UI page model. + +See sample: `api-page-delayedinsert-true.bad.al`. diff --git a/microsoft/knowledge/style/api-page-entity-naming-singular-plural.bad.al b/microsoft/knowledge/style/api-page-entity-naming-singular-plural.bad.al new file mode 100644 index 0000000..ecd1ec3 --- /dev/null +++ b/microsoft/knowledge/style/api-page-entity-naming-singular-plural.bad.al @@ -0,0 +1,10 @@ +page 50225 "Sample API Entity Bad" +{ + PageType = API; + APIPublisher = 'contoso'; + APIGroup = 'app1'; + APIVersion = 'v1.0'; + EntityName = 'customers'; + EntitySetName = 'customer'; + SourceTable = Customer; +} diff --git a/microsoft/knowledge/style/api-page-entity-naming-singular-plural.good.al b/microsoft/knowledge/style/api-page-entity-naming-singular-plural.good.al new file mode 100644 index 0000000..72981b7 --- /dev/null +++ b/microsoft/knowledge/style/api-page-entity-naming-singular-plural.good.al @@ -0,0 +1,23 @@ +page 50223 "Sample API Entity Good" +{ + PageType = API; + APIPublisher = 'contoso'; + APIGroup = 'app1'; + APIVersion = 'v1.0'; + EntityName = 'customer'; + EntitySetName = 'customers'; + SourceTable = Customer; + DelayedInsert = true; +} + +page 50224 "Sample API Compound Good" +{ + PageType = API; + APIPublisher = 'contoso'; + APIGroup = 'app1'; + APIVersion = 'v1.0'; + EntityName = 'salesOrder'; + EntitySetName = 'salesOrders'; + SourceTable = "Sales Header"; + DelayedInsert = true; +} diff --git a/microsoft/knowledge/style/api-page-entity-naming-singular-plural.md b/microsoft/knowledge/style/api-page-entity-naming-singular-plural.md new file mode 100644 index 0000000..6ba698e --- /dev/null +++ b/microsoft/knowledge/style/api-page-entity-naming-singular-plural.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: style +keywords: [api-page, entityname, entitysetname, singular, plural] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# `EntityName` is singular; `EntitySetName` is plural + +## Description + +`EntityName` and `EntitySetName` on an API page are the two halves of the OData naming contract. `EntityName` names a single record — `'customer'`, `'salesOrder'`, `'item'`. `EntitySetName` names the collection — `'customers'`, `'salesOrders'`, `'items'`. Swapping them — `EntityName = 'customers'`, `EntitySetName = 'customer'` — produces URLs that lie to consumers: `GET /customers` returns one row, `GET /customers('id')` returns a collection. The OData conventions consumers rely on for client-side code generation depend on the singular/plural pairing being correct. + +## Best Practice + +Pick the singular noun for `EntityName` and its grammatical plural for `EntitySetName`, both in camelCase. For compound nouns, only the trailing noun is pluralized: `EntityName = 'salesOrder'`, `EntitySetName = 'salesOrders'`. For nouns whose plural is irregular, use the natural English form — `EntitySetName = 'people'` for `EntityName = 'person'`. + +See sample: `api-page-entity-naming-singular-plural.good.al`. + +## Anti Pattern + +`EntityName = 'customers'`, `EntitySetName = 'customer'` — singular and plural swapped. Equally wrong is reusing the same form for both — `EntityName = 'customer'`, `EntitySetName = 'customer'` — which breaks OData metadata parsers and client codegen. + +See sample: `api-page-entity-naming-singular-plural.bad.al`. diff --git a/microsoft/knowledge/style/api-page-version-format.bad.al b/microsoft/knowledge/style/api-page-version-format.bad.al new file mode 100644 index 0000000..2efe623 --- /dev/null +++ b/microsoft/knowledge/style/api-page-version-format.bad.al @@ -0,0 +1,10 @@ +page 50222 "Sample API Version Bad" +{ + PageType = API; + APIPublisher = 'contoso'; + APIGroup = 'app1'; + APIVersion = 'v2'; + EntityName = 'customer'; + EntitySetName = 'customers'; + SourceTable = Customer; +} diff --git a/microsoft/knowledge/style/api-page-version-format.good.al b/microsoft/knowledge/style/api-page-version-format.good.al new file mode 100644 index 0000000..dc115b4 --- /dev/null +++ b/microsoft/knowledge/style/api-page-version-format.good.al @@ -0,0 +1,23 @@ +page 50220 "Sample API Version Good" +{ + PageType = API; + APIPublisher = 'contoso'; + APIGroup = 'app1'; + APIVersion = 'v1.0'; + EntityName = 'customer'; + EntitySetName = 'customers'; + SourceTable = Customer; + DelayedInsert = true; +} + +page 50221 "Sample API Beta Good" +{ + PageType = API; + APIPublisher = 'contoso'; + APIGroup = 'app1'; + APIVersion = 'beta'; + EntityName = 'preview'; + EntitySetName = 'previews'; + SourceTable = Customer; + DelayedInsert = true; +} diff --git a/microsoft/knowledge/style/api-page-version-format.md b/microsoft/knowledge/style/api-page-version-format.md new file mode 100644 index 0000000..633c53b --- /dev/null +++ b/microsoft/knowledge/style/api-page-version-format.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: style +keywords: [api-page, apiversion, version, format, beta] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# `APIVersion` must follow the pattern `vX.Y` (or `beta`) + +## Description + +The `APIVersion` property on an API page is part of the public URL path: `/api////`. The platform accepts only two value shapes for it: a `vMAJOR.MINOR` string such as `'v1.0'`, `'v2.0'`, or `'v2.1'`, or the literal string `'beta'` for pre-release endpoints. Anything else — `'v2'`, `'2.0'`, `'1'`, `'v2.0.0'` — is rejected. The major-minor pair lets consumers detect compatibility through URL inspection alone; the explicit `'beta'` channel signals "this contract may break without notice." + +## Best Practice + +Start a new public endpoint at `'v1.0'`. Bump the minor when adding fields or non-breaking changes; bump the major when changing field types, removing fields, or any breaking change. Use `'beta'` for endpoints that are still iterating and SHOULD NOT be consumed by external integrations. + +See sample: `api-page-version-format.good.al`. + +## Anti Pattern + +`APIVersion = 'v2'` (missing minor), `APIVersion = '2.0'` (missing `v` prefix), `APIVersion = 'v2.0.0'` (extra segment). All three either fail to compile or produce a URL that consumers cannot reach. + +See sample: `api-page-version-format.bad.al`. diff --git a/microsoft/knowledge/style/begin-on-same-line-as-then-else-do.bad.al b/microsoft/knowledge/style/begin-on-same-line-as-then-else-do.bad.al new file mode 100644 index 0000000..fd3ac45 --- /dev/null +++ b/microsoft/knowledge/style/begin-on-same-line-as-then-else-do.bad.al @@ -0,0 +1,14 @@ +codeunit 50235 "Sample Begin Own Line Bad" +{ + procedure Run(Condition: Boolean) + begin + if Condition then + begin + DoSomething(); + DoSomethingElse(); + end; + end; + + local procedure DoSomething() begin end; + local procedure DoSomethingElse() begin end; +} diff --git a/microsoft/knowledge/style/begin-on-same-line-as-then-else-do.good.al b/microsoft/knowledge/style/begin-on-same-line-as-then-else-do.good.al new file mode 100644 index 0000000..6043c5b --- /dev/null +++ b/microsoft/knowledge/style/begin-on-same-line-as-then-else-do.good.al @@ -0,0 +1,24 @@ +codeunit 50234 "Sample Begin Same Line Good" +{ + procedure Run(Condition: Boolean) + var + i: Integer; + begin + if Condition then begin + DoSomething(); + DoSomethingElse(); + end else begin + Reset(); + Notify(); + end; + for i := 1 to 10 do begin + DoSomething(); + DoSomethingElse(); + end; + end; + + local procedure DoSomething() begin end; + local procedure DoSomethingElse() begin end; + local procedure Reset() begin end; + local procedure Notify() begin end; +} diff --git a/microsoft/knowledge/style/begin-on-same-line-as-then-else-do.md b/microsoft/knowledge/style/begin-on-same-line-as-then-else-do.md new file mode 100644 index 0000000..fbf7aec --- /dev/null +++ b/microsoft/knowledge/style/begin-on-same-line-as-then-else-do.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: style +keywords: [begin, end, compound-statement, aa0005, codecop, formatting] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# `begin` goes on the same line as `then`, `else`, or `do` (CodeCop AA0005) + +## Description + +When a compound block follows `then`, `else`, or `do`, the `begin` keyword must sit on the same line as the preceding keyword, separated by exactly one space. `if Condition then begin` and `for i := 1 to N do begin` are correct. The form that puts `begin` on its own line — common in older AL and in languages like Pascal — is flagged by CodeCop AA0005. The rule does not change indentation of the block body; it only governs the placement of `begin` relative to `then`/`else`/`do`. + +## Best Practice + +`if Condition then begin … end;`, `else begin … end;`, `for i := 1 to N do begin … end;`. The block body is indented one level below the `if`/`for` line, and `end;` sits at the same indentation as the line that opened the block. + +See sample: `begin-on-same-line-as-then-else-do.good.al`. + +## Anti Pattern + +A line that ends with `then` (or `else`, or `do`) and is followed by a line whose only content is `begin`. The compiler accepts it but CodeCop AA0005 flags it; the visual cost is a wasted line per block and a layout that looks alien to readers used to current AL style. + +See sample: `begin-on-same-line-as-then-else-do.bad.al`. diff --git a/microsoft/knowledge/style/block-keywords-start-new-line.bad.al b/microsoft/knowledge/style/block-keywords-start-new-line.bad.al new file mode 100644 index 0000000..ef7f937 --- /dev/null +++ b/microsoft/knowledge/style/block-keywords-start-new-line.bad.al @@ -0,0 +1,15 @@ +codeunit 50239 "Sample Block Kw Bad" +{ + procedure Dispatch(IsContactName: Boolean; IsSalespersonCode: Boolean) + var + i: Integer; + begin + if IsContactName then ValidateContactName() else if IsSalespersonCode then ValidateSalespersonCode(); + for i := 1 to 10 do begin DoSomething(i); DoSomethingElse(i); end; + end; + + local procedure ValidateContactName() begin end; + local procedure ValidateSalespersonCode() begin end; + local procedure DoSomething(I: Integer) begin end; + local procedure DoSomethingElse(I: Integer) begin end; +} diff --git a/microsoft/knowledge/style/block-keywords-start-new-line.good.al b/microsoft/knowledge/style/block-keywords-start-new-line.good.al new file mode 100644 index 0000000..eb6c3d4 --- /dev/null +++ b/microsoft/knowledge/style/block-keywords-start-new-line.good.al @@ -0,0 +1,23 @@ +codeunit 50238 "Sample Block Kw Good" +{ + procedure Dispatch(IsContactName: Boolean; IsSalespersonCode: Boolean) + var + i: Integer; + begin + if IsContactName then + ValidateContactName() + else + if IsSalespersonCode then + ValidateSalespersonCode(); + + for i := 1 to 10 do begin + DoSomething(i); + DoSomethingElse(i); + end; + end; + + local procedure ValidateContactName() begin end; + local procedure ValidateSalespersonCode() begin end; + local procedure DoSomething(I: Integer) begin end; + local procedure DoSomethingElse(I: Integer) begin end; +} diff --git a/microsoft/knowledge/style/block-keywords-start-new-line.md b/microsoft/knowledge/style/block-keywords-start-new-line.md new file mode 100644 index 0000000..d40ea61 --- /dev/null +++ b/microsoft/knowledge/style/block-keywords-start-new-line.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: style +keywords: [block-keyword, end, if, repeat, until, for, while, case, aa0018] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Block keywords (`end`, `if`, `repeat`, `until`, `for`, `while`, `case`) start a new line (CodeCop AA0018) + +## Description + +CodeCop AA0018 requires that the block-introducing keywords `if`, `repeat`, `until`, `for`, `while`, `case`, and the block-terminating keyword `end` always start a new line. Multiple statements packed onto one line — `if A then X() else if B then Y();` written inline, or `for i := 1 to 10 do begin X(i); Y(i); end;` — defeat code review tooling that operates line-by-line and obscure the control flow. The rule does not prohibit short single-statement constructs spread across two lines (`if Cond then X();`); it prohibits packing the entire control structure onto one line. + +## Best Practice + +Each `if`, `else if`, `repeat`, `for`, `while`, and `case` starts a line. Each `end;` (the closing of a `begin … end` block or a `case`) starts a line. Branch bodies are on their own line, indented. + +See sample: `block-keywords-start-new-line.good.al`. + +## Anti Pattern + +`if IsContactName then ValidateContactName() else if IsSalespersonCode then ValidateSalespersonCode();` collapses an `if/else if` chain onto a single line; AA0018 flags both the `else` and the second `if`. The same applies to `for i := 1 to 10 do begin DoX(i); DoY(i); end;` — `end` is not at the start of its line. + +See sample: `block-keywords-start-new-line.bad.al`. diff --git a/microsoft/knowledge/style/caption-required-on-page-fields.bad.al b/microsoft/knowledge/style/caption-required-on-page-fields.bad.al new file mode 100644 index 0000000..bd12f36 --- /dev/null +++ b/microsoft/knowledge/style/caption-required-on-page-fields.bad.al @@ -0,0 +1,13 @@ +table 50253 "Sample Caption Bad" +{ + fields + { + field(1; "Customer No."; Code[20]) + { + } + field(2; "Is Active"; Boolean) + { + Caption = ''; + } + } +} diff --git a/microsoft/knowledge/style/caption-required-on-page-fields.good.al b/microsoft/knowledge/style/caption-required-on-page-fields.good.al new file mode 100644 index 0000000..7de715b --- /dev/null +++ b/microsoft/knowledge/style/caption-required-on-page-fields.good.al @@ -0,0 +1,17 @@ +table 50252 "Sample Caption Good" +{ + fields + { + field(1; "Customer No."; Code[20]) + { + Caption = 'Customer No.'; + } + field(2; "Enabled"; Boolean) + { + } + field(3; Amount; Decimal) + { + CaptionClass = '3,5,' + 'USD'; + } + } +} diff --git a/microsoft/knowledge/style/caption-required-on-page-fields.md b/microsoft/knowledge/style/caption-required-on-page-fields.md new file mode 100644 index 0000000..e3a69c3 --- /dev/null +++ b/microsoft/knowledge/style/caption-required-on-page-fields.md @@ -0,0 +1,28 @@ +--- +bc-version: [all] +domain: style +keywords: [caption, page-field, aa0225, aa0226, codecop, captionclass] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Every page field needs a `Caption` (CodeCop AA0225/AA0226) + +## Description + +CodeCop AA0225 and AA0226 require every field control to expose a `Caption` property, separately from the field's source name. The caption is what the user sees as the column header or label; the source name is what the code uses to reference the field. Without an explicit `Caption`, AL falls back to the source field's caption — which may be wrong for the page's context — or to the field name itself in code casing, which surfaces internal naming to users and to translators. + +Acceptable exceptions: a field whose caption is inherited via `CaptionClass = '3,5,' + CurrencyCode` (or another CaptionClass formula) does not need a literal `Caption`; the formula provides it. API pages and test pages may omit captions because their consumers are not human users. Boolean fields whose name already reads as a sentence — `Enabled`, `Posted`, `Released` — do not need a redundant Caption that repeats the name. + +## Best Practice + +`Caption = 'Customer No.';` paired with `ToolTip = 'Specifies …';`. Captions are short, noun-phrase, title-case for primary labels; sentence-case is allowed for descriptive labels that read as a sentence fragment. + +See sample: `caption-required-on-page-fields.good.al`. + +## Anti Pattern + +A field control with no `Caption` and no `CaptionClass`, or `Caption = '';`. The user sees the internal identifier as the column header and the translation pipeline has nothing to translate. + +See sample: `caption-required-on-page-fields.bad.al`. diff --git a/microsoft/knowledge/style/case-action-on-line-after-possibility.bad.al b/microsoft/knowledge/style/case-action-on-line-after-possibility.bad.al new file mode 100644 index 0000000..e4fbe58 --- /dev/null +++ b/microsoft/knowledge/style/case-action-on-line-after-possibility.bad.al @@ -0,0 +1,16 @@ +codeunit 50241 "Sample Case Format Bad" +{ + procedure Translate(Letter: Char): Code[10] + var + Letter2: Code[10]; + begin + case Letter of + 'A': Letter2 := '10'; + 'B': Letter2 := '11'; + 'C': begin Letter2 := '12'; DoSomething(); end; + end; + exit(Letter2); + end; + + local procedure DoSomething() begin end; +} diff --git a/microsoft/knowledge/style/case-action-on-line-after-possibility.good.al b/microsoft/knowledge/style/case-action-on-line-after-possibility.good.al new file mode 100644 index 0000000..a7ff731 --- /dev/null +++ b/microsoft/knowledge/style/case-action-on-line-after-possibility.good.al @@ -0,0 +1,21 @@ +codeunit 50240 "Sample Case Format Good" +{ + procedure Translate(Letter: Char): Code[10] + var + Letter2: Code[10]; + begin + case Letter of + 'A': + Letter2 := '10'; + 'B': + Letter2 := '11'; + 'C': begin + Letter2 := '12'; + DoSomething(); + end; + end; + exit(Letter2); + end; + + local procedure DoSomething() begin end; +} diff --git a/microsoft/knowledge/style/case-action-on-line-after-possibility.md b/microsoft/knowledge/style/case-action-on-line-after-possibility.md new file mode 100644 index 0000000..c928375 --- /dev/null +++ b/microsoft/knowledge/style/case-action-on-line-after-possibility.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: style +keywords: [case, statement, formatting, possibility, action, line-break] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# `case` action goes on the line after the possibility + +## Description + +In an AL `case` statement, the action for each label is written on the line that follows the label, not on the same line. `'A': Letter2 := '10';` on a single line is the discouraged form; the convention is `'A':` on one line and `Letter2 := '10';` on the next, indented one level deeper. The exception is when the action is a `begin … end` block — there the `begin` follows the colon on the same line, consistent with the rule for `then begin` / `else begin` / `do begin`. + +## Best Practice + +Each case label sits on its own line, terminated by `:`. The action below it is indented; multi-statement actions open with `begin` on the label line and close with `end;` on its own line. + +See sample: `case-action-on-line-after-possibility.good.al`. + +## Anti Pattern + +`'A': Letter2 := '10';` (single-line label and action), and `'C': begin Letter2 := '12'; DoSomething(); end;` (everything on one line including the block body). Both defeat per-line diff review and crowd the control flow. + +See sample: `case-action-on-line-after-possibility.bad.al`. diff --git a/microsoft/knowledge/style/error-passes-parameters-directly-not-strsubstno.bad.al b/microsoft/knowledge/style/error-passes-parameters-directly-not-strsubstno.bad.al new file mode 100644 index 0000000..4d47c31 --- /dev/null +++ b/microsoft/knowledge/style/error-passes-parameters-directly-not-strsubstno.bad.al @@ -0,0 +1,15 @@ +codeunit 50207 "Sample Error Params Bad" +{ + var + CustomerNotFoundErr: Label 'Customer %1 does not exist.'; + + procedure CheckCustomer(CustomerNo: Code[20]) + var + Customer: Record Customer; + begin + if not Customer.Get(CustomerNo) then + Error(StrSubstNo(CustomerNotFoundErr, CustomerNo)); + if not Customer.Get(CustomerNo) then + Error('Customer ' + CustomerNo + ' not found'); + end; +} diff --git a/microsoft/knowledge/style/error-passes-parameters-directly-not-strsubstno.good.al b/microsoft/knowledge/style/error-passes-parameters-directly-not-strsubstno.good.al new file mode 100644 index 0000000..96e3d9c --- /dev/null +++ b/microsoft/knowledge/style/error-passes-parameters-directly-not-strsubstno.good.al @@ -0,0 +1,13 @@ +codeunit 50206 "Sample Error Params Good" +{ + var + CustomerNotFoundErr: Label 'Customer %1 does not exist.'; + + procedure CheckCustomer(CustomerNo: Code[20]) + var + Customer: Record Customer; + begin + if not Customer.Get(CustomerNo) then + Error(CustomerNotFoundErr, CustomerNo); + end; +} diff --git a/microsoft/knowledge/style/error-passes-parameters-directly-not-strsubstno.md b/microsoft/knowledge/style/error-passes-parameters-directly-not-strsubstno.md new file mode 100644 index 0000000..f8ee5bb --- /dev/null +++ b/microsoft/knowledge/style/error-passes-parameters-directly-not-strsubstno.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: style +keywords: [error, strsubstno, label, parameters, concatenation, aa0231] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Pass parameters directly to `Error()`, do not wrap with `StrSubstNo` + +## Description + +`Error()` accepts a format string and a variable number of arguments — `Error(SomeLabelErr, Arg1, Arg2)`. The platform performs the substitution itself, which is the path the translation pipeline understands. Wrapping the same call as `Error(StrSubstNo(SomeLabelErr, Arg1, Arg2))` hides the placeholders from the platform and removes the format-string identity from the call-site, so analyzers cannot match the call to its label and translators lose the link between the formatted message and its template. The corresponding anti-pattern for hardcoded strings — `Error('Customer ' + CustomerNo + ' not found')` — is even worse: it builds an untranslatable, unanalyzable string at runtime. + +## Best Practice + +Declare a `Label` with the `Err` suffix and the appropriate `Comment` for placeholders, then call `Error(YourErr, arg1, arg2)`. The same rule applies to `Message`, `Confirm`, and other UI primitives: format string in, parameters as separate arguments, no `StrSubstNo` wrapper at the call site, no string concatenation. An `Error('')` (empty message) is acceptable when the calling code expects another layer to emit the actual diagnostic. + +See sample: `error-passes-parameters-directly-not-strsubstno.good.al`. + +## Anti Pattern + +`Error(StrSubstNo(CustomerNotFoundErr, CustomerNo))` and `Error(CustomerNotFoundErr + ': ' + CustomerNo)` both defeat the translation and analysis machinery. Reviewers should treat `StrSubstNo` appearing as an argument to `Error`, `Message`, `Confirm`, or `StrMenu` as an unconditional signal to rewrite. + +See sample: `error-passes-parameters-directly-not-strsubstno.bad.al`. diff --git a/microsoft/knowledge/style/event-subscriber-param-names-match-publisher.md b/microsoft/knowledge/style/event-subscriber-param-names-match-publisher.md new file mode 100644 index 0000000..aaf3729 --- /dev/null +++ b/microsoft/knowledge/style/event-subscriber-param-names-match-publisher.md @@ -0,0 +1,22 @@ +--- +bc-version: [all] +domain: style +keywords: [event-subscriber, parameter-name, publisher, signature, eventsubscriber] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Event subscriber parameter names must match the publisher signature + +## Description + +In AL, an `[EventSubscriber]` procedure is bound to its publisher by event name and parameter list. The parameter names on the subscriber are not a style choice — they must match the names the publisher declared. The compiler validates the match at build time and emits an error if the subscriber renames a parameter. This means a reviewer cannot apply a generic "use better names" pass to subscriber parameters: `Sender`, `Rec`, `xRec`, `RunTrigger`, the table-and-field-specific parameter names a publisher emits — all are dictated by the publisher and must be reproduced verbatim. + +## Best Practice + +Copy the publisher signature exactly when declaring the subscriber. When in doubt, navigate to the publisher (`OnAfterValidateEvent`, `OnBeforePostSalesDoc`, etc.) and copy its parameter list. Style rules that apply to other locals — descriptive names, no spaces — do not apply to subscriber parameters. + +## Anti Pattern + +Renaming a publisher parameter to look prettier in the subscriber. The build breaks immediately. More insidiously, a parameter name that happens to match by coincidence in one event publisher but not in a similar one will compile in some versions of BC and fail in others when the publisher signature evolves. diff --git a/microsoft/knowledge/style/fieldcaption-not-fieldname-in-user-messages.bad.al b/microsoft/knowledge/style/fieldcaption-not-fieldname-in-user-messages.bad.al new file mode 100644 index 0000000..4328ce2 --- /dev/null +++ b/microsoft/knowledge/style/fieldcaption-not-fieldname-in-user-messages.bad.al @@ -0,0 +1,13 @@ +tableextension 50211 "Sample FieldCaption Bad" extends Customer +{ + procedure ConfirmAndAnnounce(): Boolean + var + UpdateLocationQst: Label 'Update %1?'; + UpdatedMsg: Label 'Updated %1.'; + begin + if not Confirm(UpdateLocationQst, true, FieldName("Location Code")) then + exit(false); + Message(UpdatedMsg, TableName()); + exit(true); + end; +} diff --git a/microsoft/knowledge/style/fieldcaption-not-fieldname-in-user-messages.good.al b/microsoft/knowledge/style/fieldcaption-not-fieldname-in-user-messages.good.al new file mode 100644 index 0000000..449b98d --- /dev/null +++ b/microsoft/knowledge/style/fieldcaption-not-fieldname-in-user-messages.good.al @@ -0,0 +1,13 @@ +tableextension 50210 "Sample FieldCaption Good" extends Customer +{ + procedure ConfirmAndAnnounce(): Boolean + var + UpdateLocationQst: Label 'Update %1?'; + UpdatedMsg: Label 'Updated %1.'; + begin + if not Confirm(UpdateLocationQst, true, FieldCaption("Location Code")) then + exit(false); + Message(UpdatedMsg, TableCaption()); + exit(true); + end; +} diff --git a/microsoft/knowledge/style/fieldcaption-not-fieldname-in-user-messages.md b/microsoft/knowledge/style/fieldcaption-not-fieldname-in-user-messages.md new file mode 100644 index 0000000..a74f1aa --- /dev/null +++ b/microsoft/knowledge/style/fieldcaption-not-fieldname-in-user-messages.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: style +keywords: [fieldcaption, fieldname, tablecaption, tablename, translation, message, error] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Use FieldCaption/TableCaption (not FieldName/TableName) in user-facing text + +## Description + +`FieldName` and `TableName` return the developer-facing identifier of a field or table — a fixed English string used in metadata and in code. `FieldCaption` and `TableCaption` return the translated, user-facing label declared by the field's or table's `Caption` property. When the value is embedded in a `Message`, `Error`, `Confirm`, or any other string shown to a user, the caption is the correct source. Otherwise the user sees the English internal name regardless of locale, and any caption change must be re-applied at every call site instead of being picked up from the single point of definition. + +## Best Practice + +Reach for `FieldCaption("Location Code")` and `TableCaption()` whenever the value flows into a UI primitive. The same rule applies to format parameters: `Error(SomeErr, FieldCaption("Status"), TableCaption(), "Status")` rather than `Error(SomeErr, FieldName("Status"), TableName(), "Status")`. The captions follow the user's language; the names do not. + +See sample: `fieldcaption-not-fieldname-in-user-messages.good.al`. + +## Anti Pattern + +`Message('Updated %1', TableName())` or `Confirm(UpdateLocationQst, true, FieldName("Location Code"))`. The user sees the English internal name in every locale, and any future rename of the caption fails to reach the message. + +See sample: `fieldcaption-not-fieldname-in-user-messages.bad.al`. diff --git a/microsoft/knowledge/style/file-name-object-type-pattern.md b/microsoft/knowledge/style/file-name-object-type-pattern.md new file mode 100644 index 0000000..dd8e0f1 --- /dev/null +++ b/microsoft/knowledge/style/file-name-object-type-pattern.md @@ -0,0 +1,24 @@ +--- +bc-version: [all] +domain: style +keywords: [file-name, object-type, suffix, naming-convention] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Name AL source files `..al` + +## Description + +Each AL source file holds a single object, and the file name is expected to be of the form `..al` — `CustomerCard.Page.al`, `PostSalesInvoice.Codeunit.al`, `NoSeriesTests.Codeunit.al`, `SalesHeader.TableExt.al`. The pattern makes object types greppable from a file listing and lets tooling — symbol search, project explorers, code generators — locate objects without parsing the AL source. Snake-case, lowercase-only, or type-less file names (`customer_page.al`, `tests_noSeries.al`, `PostSalesInvoiceLogic.al`) all break that contract. + +## Best Practice + +Use PascalCase for the object portion, no spaces, no underscores; the type segment is one of the AL object-type names — `Page`, `Codeunit`, `Table`, `TableExt`, `Report`, `Query`, `XmlPort`, `Enum`, `EnumExt`, `Interface`, `PermissionSet`, `PageExt`, `ReportExt`. The object portion should echo the object's name as it appears in AL. + +See sample (file-naming pattern is structural; no AL sample shipped here). + +## Anti Pattern + +`customer_page.al`, `PostSalesInvoiceLogic.al`, `tests_noSeries.al`. The first uses snake_case and lower-case; the second omits the type segment entirely; the third inverts the order and uses mixed casing. All three break grep, symbol search, and the implicit map between file system and AL object table. diff --git a/microsoft/knowledge/style/function-call-parentheses-required.bad.al b/microsoft/knowledge/style/function-call-parentheses-required.bad.al new file mode 100644 index 0000000..2677716 --- /dev/null +++ b/microsoft/knowledge/style/function-call-parentheses-required.bad.al @@ -0,0 +1,11 @@ +codeunit 50213 "Sample Parens Bad" +{ + procedure Run() + var + Customer: Record Customer; + begin + Customer.Init; + if Customer.FindFirst then + Customer.Modify; + end; +} diff --git a/microsoft/knowledge/style/function-call-parentheses-required.good.al b/microsoft/knowledge/style/function-call-parentheses-required.good.al new file mode 100644 index 0000000..53f6f85 --- /dev/null +++ b/microsoft/knowledge/style/function-call-parentheses-required.good.al @@ -0,0 +1,11 @@ +codeunit 50212 "Sample Parens Good" +{ + procedure Run() + var + Customer: Record Customer; + begin + Customer.Init(); + if Customer.FindFirst() then + Customer.Modify(); + end; +} diff --git a/microsoft/knowledge/style/function-call-parentheses-required.md b/microsoft/knowledge/style/function-call-parentheses-required.md new file mode 100644 index 0000000..31fbaf8 --- /dev/null +++ b/microsoft/knowledge/style/function-call-parentheses-required.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: style +keywords: [parentheses, function-call, method-call, aa0008, codecop] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Always write parentheses on procedure calls (CodeCop AA0008) + +## Description + +AL allows a parameterless procedure to be called without parentheses — `Customer.Init` instead of `Customer.Init()` — and the result is syntactically identical at runtime. CodeCop AA0008 still flags the parenthesis-less form. The reason is twofold: written without parentheses, a procedure call is visually indistinguishable from a property read, which makes BC code harder to scan; and the same identifier may exist as both a property and a procedure on different objects, so the parentheses are the only local signal that this is a call. The rule applies to every parameterless invocation, including `Init`, `Insert`, `Modify`, `Delete`, `DeleteAll`, `FindFirst`, `FindSet`, `Next`, `Get`, `CalcFields`, and user-defined procedures. + +## Best Practice + +Always write `()` on a procedure call, even when it takes no arguments: `Customer.Init();`, `TempBuffer.DeleteAll();`, `if Customer.FindFirst() then …`. The same applies inside expressions and as a condition. + +See sample: `function-call-parentheses-required.good.al`. + +## Anti Pattern + +`Customer.Init;`, `TempBuffer.DeleteAll;`, `if Customer.FindFirst then …`. Every one of those is an AA0008 violation. Reviewers should treat a parameterless procedure name appearing without parentheses as a defect, even though the compiler accepts it. + +See sample: `function-call-parentheses-required.bad.al`. diff --git a/microsoft/knowledge/style/label-comment-explains-placeholders.bad.al b/microsoft/knowledge/style/label-comment-explains-placeholders.bad.al new file mode 100644 index 0000000..7f5893b --- /dev/null +++ b/microsoft/knowledge/style/label-comment-explains-placeholders.bad.al @@ -0,0 +1,11 @@ +codeunit 50203 "Sample Label Comment Bad" +{ + var + DocumentErrorErr: Label 'Document %1 has errors in %2.'; + ValidationErr: Label 'Field %1 in table %2 contains invalid value %3.'; + + procedure Validate(DocNo: Code[20]; Loc: Code[10]) + begin + Error(DocumentErrorErr, DocNo, Loc); + end; +} diff --git a/microsoft/knowledge/style/label-comment-explains-placeholders.good.al b/microsoft/knowledge/style/label-comment-explains-placeholders.good.al new file mode 100644 index 0000000..7318333 --- /dev/null +++ b/microsoft/knowledge/style/label-comment-explains-placeholders.good.al @@ -0,0 +1,12 @@ +codeunit 50202 "Sample Label Comment Good" +{ + var + CustomerNotFoundErr: Label 'Customer %1 does not exist for sales document %2.', Comment = '%1 = Customer No., %2 = Sales Header No.'; + ValidationErr: Label 'Field %1 in table %2 contains invalid value %3.', Comment = '%1 = Field Name, %2 = Table Caption, %3 = Field Value'; + CustomerSimpleLbl: Label 'Customer %1'; + + procedure Validate(CustNo: Code[20]; DocNo: Code[20]) + begin + Error(CustomerNotFoundErr, CustNo, DocNo); + end; +} diff --git a/microsoft/knowledge/style/label-comment-explains-placeholders.md b/microsoft/knowledge/style/label-comment-explains-placeholders.md new file mode 100644 index 0000000..43d9c1f --- /dev/null +++ b/microsoft/knowledge/style/label-comment-explains-placeholders.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: style +keywords: [label, comment, placeholder, strsubstno, translation, aa0470] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Document each Label placeholder with the Comment parameter + +## Description + +`Label` and `TextConst` strings that contain placeholders (`%1`, `%2`, …) need a `Comment` parameter that names what each placeholder is. Translators do not see the call site, so without the Comment they cannot disambiguate `'Customer %1 not found in %2.'` — is `%2` a location code, a posting date, a company name? The pattern is `Comment = '%1 = , %2 = '`. The Comment is not required when the placeholder meaning is obvious from the surrounding text — `'Customer %1'` is unambiguously a Customer No. — but for any non-trivial label the Comment is a hard requirement. + +## Best Practice + +Write the Comment in the form `'%1 = Customer No., %2 = Sales Header No.'` — one entry per placeholder, matched by ordinal, named in the vocabulary of the BC domain. When the label is reused across multiple call sites, the Comment names the canonical meaning all call sites must conform to. + +See sample: `label-comment-explains-placeholders.good.al`. + +## Anti Pattern + +A label with two or more placeholders and no Comment, leaving the translator to guess. Equally bad is a Comment that only restates the placeholders (`'%1 and %2 are values'`) without naming what they are. Both fail in translation: the localized string ends up grammatically or semantically wrong, and the bug surfaces only in a non-English tenant. + +See sample: `label-comment-explains-placeholders.bad.al`. diff --git a/microsoft/knowledge/style/label-locked-for-non-translatable.bad.al b/microsoft/knowledge/style/label-locked-for-non-translatable.bad.al new file mode 100644 index 0000000..0ec72e8 --- /dev/null +++ b/microsoft/knowledge/style/label-locked-for-non-translatable.bad.al @@ -0,0 +1,7 @@ +codeunit 50205 "Sample Locked Label Bad" +{ + var + HttpsUrl: Label 'https://example.com'; + GetVerbTok: Label 'GET'; + JsonTypeLbl: Label 'application/json'; +} diff --git a/microsoft/knowledge/style/label-locked-for-non-translatable.good.al b/microsoft/knowledge/style/label-locked-for-non-translatable.good.al new file mode 100644 index 0000000..02b35d9 --- /dev/null +++ b/microsoft/knowledge/style/label-locked-for-non-translatable.good.al @@ -0,0 +1,8 @@ +codeunit 50204 "Sample Locked Label Good" +{ + var + GetMethodTok: Label 'GET', Locked = true; + ContentTypeJsonTok: Label 'application/json', Locked = true; + ApiBaseUrlTok: Label 'https://api.contoso.com/v1', Locked = true; + TelemetryStartTxt: Label 'Operation started for %1.', Locked = true; +} diff --git a/microsoft/knowledge/style/label-locked-for-non-translatable.md b/microsoft/knowledge/style/label-locked-for-non-translatable.md new file mode 100644 index 0000000..77f054b --- /dev/null +++ b/microsoft/knowledge/style/label-locked-for-non-translatable.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: style +keywords: [label, locked, translation, token, url, json, xml] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Set `Locked = true` on Labels that must not be translated + +## Description + +A `Label` is by default surfaced to translators and rewritten per locale. That is wrong for strings that are not natural language: HTTP verbs (`GET`, `PUT`), URL fragments, JSON/XML snippets, content-type strings, GUIDs, application keys, and field tokens used by integrations. Translating these breaks the integration the moment a non-English tenant runs the code. The `Locked = true` parameter on the Label declaration tells the translation pipeline to keep the string verbatim, and signals to reviewers that the value is part of a wire-level contract rather than display text. + +## Best Practice + +Pair `Locked = true` with the `Tok` suffix for short tokens (`GetMethodTok: Label 'GET', Locked = true;`) and with the `Txt` suffix for telemetry strings that contain format placeholders but should not be localized. The `Locked` parameter and the `Tok` / `Txt` suffix together make the intent unambiguous. + +See sample: `label-locked-for-non-translatable.good.al`. + +## Anti Pattern + +`HttpsUrl: Label 'https://example.com';` or `ContentTypeTok: Label 'application/json';` declared without `Locked = true`. The translator localizes them, the integration fails in production for the affected tenant, and the failure is invisible in the developer's English-locale tests. + +See sample: `label-locked-for-non-translatable.bad.al`. diff --git a/microsoft/knowledge/style/label-suffix-approved-list.bad.al b/microsoft/knowledge/style/label-suffix-approved-list.bad.al new file mode 100644 index 0000000..6b227de --- /dev/null +++ b/microsoft/knowledge/style/label-suffix-approved-list.bad.al @@ -0,0 +1,14 @@ +codeunit 50201 "Sample Label Suffix Bad" +{ + var + CannotDeleteLine: Label 'Cannot delete this line.'; + Text000: Label 'Update complete'; + UpdateLocation: Label 'Update location?'; + WrongSuffixTok: Label 'Customer %1 not found.'; + + procedure ShowMessages() + begin + Error(WrongSuffixTok, '10000'); + Message(Text000); + end; +} diff --git a/microsoft/knowledge/style/label-suffix-approved-list.good.al b/microsoft/knowledge/style/label-suffix-approved-list.good.al new file mode 100644 index 0000000..f3ec561 --- /dev/null +++ b/microsoft/knowledge/style/label-suffix-approved-list.good.al @@ -0,0 +1,15 @@ +codeunit 50200 "Sample Label Suffix Good" +{ + var + UpdateCompleteMsg: Label 'Update complete.'; + CustomerNotFoundErr: Label 'Customer %1 does not exist.'; + DeleteRecordQst: Label 'Delete this record?'; + CustomerNameLbl: Label 'Customer Name'; + GetMethodTok: Label 'GET', Locked = true; + TelemetryStartedTxt: Label 'Operation started for customer %1.', Locked = true; + + procedure ShowMessage() + begin + Message(UpdateCompleteMsg); + end; +} diff --git a/microsoft/knowledge/style/label-suffix-approved-list.md b/microsoft/knowledge/style/label-suffix-approved-list.md new file mode 100644 index 0000000..8e937f3 --- /dev/null +++ b/microsoft/knowledge/style/label-suffix-approved-list.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: style +keywords: [label, textconst, suffix, aa0074, codecop, msg, err, qst, lbl, tok] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Use approved suffixes on Label and TextConst names (CodeCop AA0074) + +## Description + +CodeCop AA0074 flags `Label` and `TextConst` identifiers that do not end with an approved usage suffix. The suffix signals at the call site how the text is consumed and what translation behaviour it should get. The approved suffixes and their intended usage are: `Msg` for text shown via `Message()`; `Err` for text passed to `Error()`; `Qst` for text used with `Confirm` or `StrMenu`; `Lbl` for captions and tooltips; `Tok` for short tokens such as `'GET'`, `'PUT'`, `'HTTPS'`, GUIDs, or JSON/XML snippets that are not translated (typically with `Locked = true`); and `Txt` for general text including telemetry messages. A `Label` named `Text000` or `CannotDeleteLine` without a suffix violates the rule, regardless of how readable the prose is. + +## Best Practice + +Pick the suffix that matches the call where the label is consumed: `UpdateCompleteMsg` for `Message(...)`, `CustomerNotFoundErr` for `Error(...)`, `DeleteRecordQst` for `Confirm(...)`, `CustomerNameLbl` for tooltips and captions, `GetMethodTok` for locked tokens, `TelemetryDataTxt` for telemetry payloads. Suffix choices between `Tok`, `Lbl`, `Txt`, and `Msg` are judgment calls when the suffix is valid for the usage — what matters is that the suffix is on the approved list and matches the actual call. + +See sample: `label-suffix-approved-list.good.al`. + +## Anti Pattern + +A `Label` declared with no suffix (`CannotDeleteLine: Label '…';`), a generic name (`Text000: Label '…';`), or a suffix that contradicts the usage (`WrongSuffixTok: Label 'Customer %1 not found.'` then passed to `Error()`). All three trip AA0074 or its reviewers and obscure the call-site contract. + +See sample: `label-suffix-approved-list.bad.al`. diff --git a/microsoft/knowledge/style/lowercase-reserved-keywords.bad.al b/microsoft/knowledge/style/lowercase-reserved-keywords.bad.al new file mode 100644 index 0000000..83d5994 --- /dev/null +++ b/microsoft/knowledge/style/lowercase-reserved-keywords.bad.al @@ -0,0 +1,14 @@ +codeunit 50245 "Sample Upper Keywords Bad" +{ + procedure Walk(VAR Customer: Record Customer) + VAR + Found: Boolean; + BEGIN + IF Customer.FindSet() THEN + REPEAT + Found := TRUE; + UNTIL Customer.Next() = 0; + IF Found THEN + EXIT; + END; +} diff --git a/microsoft/knowledge/style/lowercase-reserved-keywords.good.al b/microsoft/knowledge/style/lowercase-reserved-keywords.good.al new file mode 100644 index 0000000..25fb20e --- /dev/null +++ b/microsoft/knowledge/style/lowercase-reserved-keywords.good.al @@ -0,0 +1,14 @@ +codeunit 50244 "Sample Lower Keywords Good" +{ + procedure Walk(var Customer: Record Customer) + var + Found: Boolean; + begin + if Customer.FindSet() then + repeat + Found := true; + until Customer.Next() = 0; + if Found then + exit; + end; +} diff --git a/microsoft/knowledge/style/lowercase-reserved-keywords.md b/microsoft/knowledge/style/lowercase-reserved-keywords.md new file mode 100644 index 0000000..f14b973 --- /dev/null +++ b/microsoft/knowledge/style/lowercase-reserved-keywords.md @@ -0,0 +1,28 @@ +--- +bc-version: [all] +domain: style +keywords: [reserved-keyword, lowercase, aa0241, codecop, if, then, begin] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Reserved keywords are written in lowercase (CodeCop AA0241) + +## Description + +CodeCop AA0241 requires reserved AL keywords — `if`, `then`, `else`, `begin`, `end`, `var`, `procedure`, `local`, `internal`, `for`, `while`, `repeat`, `until`, `case`, `of`, `do`, `not`, `and`, `or`, `exit`, `break`, `skip`, `quit`, and the rest — to be lowercase. Old Navision and C/AL code used `IF…THEN…BEGIN…END` in uppercase, and that style still lingers in training data and legacy modules. New AL code is lowercase. The rule applies to keywords only — type names (`Record`, `Codeunit`, `Integer`), property names (`Caption`, `ToolTip`), and identifiers are unaffected. + +Test codeunits that retain legacy uppercase forms (`OPENEDIT`, `ASSERTERROR`, `VALUE`) are an accepted exception: the test framework historically uses those identifiers and rewriting them brings no benefit. The rule applies to new code in modified lines, not to long-standing test patterns. + +## Best Practice + +Write keywords lowercase: `if Condition then begin … end;`, `repeat … until Found;`, `for i := 1 to N do …`. The standard AL formatter normalizes casing automatically. + +See sample: `lowercase-reserved-keywords.good.al`. + +## Anti Pattern + +`IF Condition THEN BEGIN DoSomething(); END;`, `REPEAT GetNext(); UNTIL Found;`. Uppercase keywords trip AA0241 and signal C/AL-era code that has not been modernized. + +See sample: `lowercase-reserved-keywords.bad.al`. diff --git a/microsoft/knowledge/style/named-invocations-not-object-ids.bad.al b/microsoft/knowledge/style/named-invocations-not-object-ids.bad.al new file mode 100644 index 0000000..47e25d8 --- /dev/null +++ b/microsoft/knowledge/style/named-invocations-not-object-ids.bad.al @@ -0,0 +1,12 @@ +codeunit 50209 "Sample Named Invocations Bad" +{ + procedure ShowShipmentLines(var SalesShptLine: Record "Sales Shipment Line") + begin + Page.RunModal(525, SalesShptLine); + end; + + procedure RunInvoiceReport() + begin + Report.Run(206, true); + end; +} diff --git a/microsoft/knowledge/style/named-invocations-not-object-ids.good.al b/microsoft/knowledge/style/named-invocations-not-object-ids.good.al new file mode 100644 index 0000000..8586984 --- /dev/null +++ b/microsoft/knowledge/style/named-invocations-not-object-ids.good.al @@ -0,0 +1,12 @@ +codeunit 50208 "Sample Named Invocations Good" +{ + procedure ShowShipmentLines(var SalesShptLine: Record "Sales Shipment Line") + begin + Page.RunModal(Page::"Posted Sales Shipment Lines", SalesShptLine); + end; + + procedure RunInvoiceReport() + begin + Report.Run(Report::"Sales - Invoice", true); + end; +} diff --git a/microsoft/knowledge/style/named-invocations-not-object-ids.md b/microsoft/knowledge/style/named-invocations-not-object-ids.md new file mode 100644 index 0000000..413dfc2 --- /dev/null +++ b/microsoft/knowledge/style/named-invocations-not-object-ids.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: style +keywords: [page, report, codeunit, runmodal, run, object-id, named-invocation] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Call objects by name, not by numeric ID + +## Description + +`Page.RunModal`, `Report.Run`, `Codeunit.Run`, and the `Page::`, `Report::`, `Codeunit::`, `Table::`, `XmlPort::` selectors accept either a numeric ID or a named alias. The named form — `Page::"Posted Sales Shipment Lines"`, `Report::"Sales - Invoice"` — is the one to use. Numeric IDs are an implementation detail that change with renumbering, do not survive a rename, and carry no signal to a reader about what the call actually does. The compiler resolves named aliases at build time, so the named form is no slower than the numeric form. + +## Best Practice + +When invoking an object whose named alias is available in the same app (or in a dependency the current app already references), use the named form: `Page.RunModal(Page::"Posted Sales Shipment Lines", SalesShptLine)`, `Report.Run(Report::"Sales - Invoice", true)`. The same applies to `Codeunit.Run`, `XmlPort.Run`, `Query.Open`, and any platform method that takes an object reference. The named form makes diffs reviewable — a rename is visible — and makes log output and stack traces interpretable. + +See sample: `named-invocations-not-object-ids.good.al`. + +## Anti Pattern + +`Page.RunModal(525, …)` or `Report.Run(206, true)`. The numeric form is unreadable, fragile across renumbering, and breaks every search that looks for callers of a named object. + +See sample: `named-invocations-not-object-ids.bad.al`. diff --git a/microsoft/knowledge/style/no-begin-end-around-single-statement.bad.al b/microsoft/knowledge/style/no-begin-end-around-single-statement.bad.al new file mode 100644 index 0000000..1803e1c --- /dev/null +++ b/microsoft/knowledge/style/no-begin-end-around-single-statement.bad.al @@ -0,0 +1,11 @@ +codeunit 50237 "Sample Single Stmt Bad" +{ + procedure Validate(IsAssemblyOutputLine: Boolean) + var + SalesLine: Record "Sales Line"; + begin + if IsAssemblyOutputLine then begin + SalesLine.TestField("Order Line No.", 0); + end; + end; +} diff --git a/microsoft/knowledge/style/no-begin-end-around-single-statement.good.al b/microsoft/knowledge/style/no-begin-end-around-single-statement.good.al new file mode 100644 index 0000000..684a86c --- /dev/null +++ b/microsoft/knowledge/style/no-begin-end-around-single-statement.good.al @@ -0,0 +1,10 @@ +codeunit 50236 "Sample Single Stmt Good" +{ + procedure Validate(IsAssemblyOutputLine: Boolean) + var + SalesLine: Record "Sales Line"; + begin + if IsAssemblyOutputLine then + SalesLine.TestField("Order Line No.", 0); + end; +} diff --git a/microsoft/knowledge/style/no-begin-end-around-single-statement.md b/microsoft/knowledge/style/no-begin-end-around-single-statement.md new file mode 100644 index 0000000..d7665f7 --- /dev/null +++ b/microsoft/knowledge/style/no-begin-end-around-single-statement.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: style +keywords: [begin, end, single-statement, aa0013, codecop, compound] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Do not wrap a single statement in `begin … end` (CodeCop AA0013) + +## Description + +CodeCop AA0013 flags `begin … end` blocks that contain exactly one statement. The compound-block syntax exists to group multiple statements as a unit; using it for a single statement adds two lines and a level of nesting without adding meaning. `if IsAssemblyOutputLine then begin TestField("Order Line No.", 0); end;` should be `if IsAssemblyOutputLine then TestField("Order Line No.", 0);` — one statement, no block. The same logic applies after `else`, `for`, `while`, and `repeat`. + +## Best Practice + +A single statement following `then`, `else`, `do`, or a case label is written on its own line, indented one level, with no `begin … end`. Use `begin … end` only when there are two or more statements to group. + +See sample: `no-begin-end-around-single-statement.good.al`. + +## Anti Pattern + +`if Cond then begin OneCall(); end;` — single statement wrapped in a block. AA0013 flags it. The reviewer signal is "a `begin` followed by exactly one statement before its `end`." + +See sample: `no-begin-end-around-single-statement.bad.al`. diff --git a/microsoft/knowledge/style/no-else-after-terminating-statement.bad.al b/microsoft/knowledge/style/no-else-after-terminating-statement.bad.al new file mode 100644 index 0000000..eefa888 --- /dev/null +++ b/microsoft/knowledge/style/no-else-after-terminating-statement.bad.al @@ -0,0 +1,13 @@ +codeunit 50243 "Sample Redundant Else Bad" +{ + procedure Validate(IsAdjmtBinCodeChanged: Boolean) + var + AdjmtBinErr: Label 'Adjustment bin code change not allowed.'; + BinCodeErr: Label 'Bin code change not allowed.'; + begin + if IsAdjmtBinCodeChanged then + Error(AdjmtBinErr) + else + Error(BinCodeErr); + end; +} diff --git a/microsoft/knowledge/style/no-else-after-terminating-statement.good.al b/microsoft/knowledge/style/no-else-after-terminating-statement.good.al new file mode 100644 index 0000000..15a0941 --- /dev/null +++ b/microsoft/knowledge/style/no-else-after-terminating-statement.good.al @@ -0,0 +1,12 @@ +codeunit 50242 "Sample No Else Good" +{ + procedure Validate(IsAdjmtBinCodeChanged: Boolean) + var + AdjmtBinErr: Label 'Adjustment bin code change not allowed.'; + BinCodeErr: Label 'Bin code change not allowed.'; + begin + if IsAdjmtBinCodeChanged then + Error(AdjmtBinErr); + Error(BinCodeErr); + end; +} diff --git a/microsoft/knowledge/style/no-else-after-terminating-statement.md b/microsoft/knowledge/style/no-else-after-terminating-statement.md new file mode 100644 index 0000000..76d7ede --- /dev/null +++ b/microsoft/knowledge/style/no-else-after-terminating-statement.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: style +keywords: [else, exit, break, skip, quit, error, terminating, control-flow] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Omit `else` when the `then` branch ends with `exit`, `break`, `skip`, `quit`, or `error` + +## Description + +When the `then` branch of an `if` ends in a terminating statement — `exit`, `break`, `skip`, `quit`, or `error` — the `else` branch becomes the natural fall-through. `if Cond then exit; DoX();` and `if Cond then exit else DoX();` are equivalent, and the second form adds a layer of nesting that the reader has to mentally flatten. The same applies to `Error(...)`: `if IsAdjmtBinCodeChanged() then Error(AdjmtErr) else Error(BinErr);` is better written as `if IsAdjmtBinCodeChanged() then Error(AdjmtErr); Error(BinErr);` — the second `Error` is always reached when the first branch is not taken. + +## Best Practice + +Drop the `else` when the `then` branch unconditionally exits the procedure or the enclosing loop. The body that would have been inside `else` becomes the unindented continuation. + +See sample: `no-else-after-terminating-statement.good.al`. + +## Anti Pattern + +An `if … then Error(…) else Error(…)` pair where both branches terminate. The `else` is structural noise — the reader cannot tell at a glance whether it exists to handle an actual continuation or simply mirrors the `then`. The fix is to drop `else` and let the second `Error` fall through naturally. + +See sample: `no-else-after-terminating-statement.bad.al`. diff --git a/microsoft/knowledge/style/no-space-before-method-parenthesis.bad.al b/microsoft/knowledge/style/no-space-before-method-parenthesis.bad.al new file mode 100644 index 0000000..b2f8295 --- /dev/null +++ b/microsoft/knowledge/style/no-space-before-method-parenthesis.bad.al @@ -0,0 +1,11 @@ +codeunit 50231 "Sample No Space Paren Bad" +{ + procedure Lookup(CustomerNo: Code[20]) + var + Customer: Record Customer; + GreetingMsg: Label 'Hello %1'; + begin + if Customer.Get ( CustomerNo ) then + Message ( GreetingMsg, Customer.Name ); + end; +} diff --git a/microsoft/knowledge/style/no-space-before-method-parenthesis.good.al b/microsoft/knowledge/style/no-space-before-method-parenthesis.good.al new file mode 100644 index 0000000..eb16dc3 --- /dev/null +++ b/microsoft/knowledge/style/no-space-before-method-parenthesis.good.al @@ -0,0 +1,11 @@ +codeunit 50230 "Sample No Space Paren Good" +{ + procedure Lookup(CustomerNo: Code[20]) + var + Customer: Record Customer; + GreetingMsg: Label 'Hello %1'; + begin + if Customer.Get(CustomerNo) then + Message(GreetingMsg, Customer.Name); + end; +} diff --git a/microsoft/knowledge/style/no-space-before-method-parenthesis.md b/microsoft/knowledge/style/no-space-before-method-parenthesis.md new file mode 100644 index 0000000..d7a2f69 --- /dev/null +++ b/microsoft/knowledge/style/no-space-before-method-parenthesis.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: style +keywords: [spacing, parenthesis, method-call, aa0002, codecop] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# No space between a method name and its opening parenthesis (CodeCop AA0002) + +## Description + +CodeCop AA0002 forbids whitespace between a procedure/method name and its `(`. `Customer.Get(CustomerNo)` is correct; `Customer.Get (CustomerNo)` is not. The rule applies to user-defined procedures, system methods (`Insert`, `FindFirst`, `CalcFields`), trigger-style invocations, and the parenthesised cast/conversion forms (`Format(Value)`, `CopyStr(Source, 1, 10)`). The whitespace between `(` and the first argument, and between the last argument and `)`, is also forbidden by the same rule. + +## Best Practice + +`Customer.Get(CustomerNo)`, `Customer.SetFilter("No.", '%1', '*A*')`, `Message(GreetingMsg, UserName)`. The standard AL formatter enforces this automatically. + +See sample: `no-space-before-method-parenthesis.good.al`. + +## Anti Pattern + +`Customer.Get ( CustomerNo )`, `Message ( GreetingMsg, UserName )`. Both trip AA0002 and read as if the call had an extra unnamed parameter — a small but persistent friction every reader pays. + +See sample: `no-space-before-method-parenthesis.bad.al`. diff --git a/microsoft/knowledge/style/object-name-30-char-limit.md b/microsoft/knowledge/style/object-name-30-char-limit.md new file mode 100644 index 0000000..f0e96ee --- /dev/null +++ b/microsoft/knowledge/style/object-name-30-char-limit.md @@ -0,0 +1,22 @@ +--- +bc-version: [all] +domain: style +keywords: [object-name, length, prefix, affix, 30-characters, appsource] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Keep object names within the 30-character platform limit + +## Description + +Business Central object names — for tables, pages, codeunits, reports, queries, XML ports, enums, and permission sets — are limited to 30 characters in total. AppSource and per-tenant extensions also have to carry a mandatory prefix or affix (typically 3–4 characters), which leaves roughly 26 characters for the descriptive part of the name. Names hitting the 30-character ceiling are routinely rejected at publish time, and over-aggressive abbreviation to fit (`CustLE`, `SIPoster`, `SalesInv`) makes the object name opaque to reviewers and to anyone reading dependency lists. The right move is to plan name length around the budget — descriptive base + prefix — not to discover the limit during AppSource validation. + +## Best Practice + +Choose a clear, descriptive name in the 20–26-character range and reserve the remaining characters for the mandatory app prefix. `"Customer Ledger Entry"`, `"Sales Invoice Posting"`, `"Sales Invoice"` are descriptive and well under the budget. When you genuinely need to abbreviate, prefer abbreviations that are already established in BC (`Cust.`, `Vend.`, `Gen. Jnl.`, `WHSE`) over ad-hoc shortenings. + +## Anti Pattern + +Names like `"CustLE"` or `"SIPoster"` that abbreviate beyond comprehensibility, or names like `"Customer Ledger Entry Posting Helper Codeunit"` that breach 30 characters and force a rename during publish. diff --git a/microsoft/knowledge/style/optioncaption-required-and-matches-membercount.bad.al b/microsoft/knowledge/style/optioncaption-required-and-matches-membercount.bad.al new file mode 100644 index 0000000..9d5269c --- /dev/null +++ b/microsoft/knowledge/style/optioncaption-required-and-matches-membercount.bad.al @@ -0,0 +1,17 @@ +table 50255 "Sample OptionCaption Bad" +{ + fields + { + field(1; Status; Option) + { + Caption = 'Status'; + OptionMembers = Open,Released,Pending; + } + field(2; Priority; Option) + { + Caption = 'Priority'; + OptionMembers = Low,Medium,High,Critical; + OptionCaption = 'Low,Medium,High'; + } + } +} diff --git a/microsoft/knowledge/style/optioncaption-required-and-matches-membercount.good.al b/microsoft/knowledge/style/optioncaption-required-and-matches-membercount.good.al new file mode 100644 index 0000000..d0e9866 --- /dev/null +++ b/microsoft/knowledge/style/optioncaption-required-and-matches-membercount.good.al @@ -0,0 +1,18 @@ +table 50254 "Sample OptionCaption Good" +{ + fields + { + field(1; Status; Option) + { + Caption = 'Status'; + OptionMembers = Open,Released,Pending; + OptionCaption = 'Open,Released,Pending'; + } + field(2; Priority; Option) + { + Caption = 'Priority'; + OptionMembers = Low,Medium,High,Critical; + OptionCaption = 'Low,Medium,High,Critical'; + } + } +} diff --git a/microsoft/knowledge/style/optioncaption-required-and-matches-membercount.md b/microsoft/knowledge/style/optioncaption-required-and-matches-membercount.md new file mode 100644 index 0000000..d961040 --- /dev/null +++ b/microsoft/knowledge/style/optioncaption-required-and-matches-membercount.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: style +keywords: [optioncaption, option, member-count, aa0221, aa0223, aa0224] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Option fields need `OptionCaption`, and its element count must match `OptionMembers` (CodeCop AA0221/AA0223/AA0224) + +## Description + +CodeCop AA0221 requires an `OptionCaption` on every option-type field that is not sourced from a table column (table-sourced option fields inherit the captions of the underlying field). AA0223 and AA0224 add two integrity checks: the number of comma-separated entries in `OptionCaption` must equal the number of entries in `OptionMembers`, and each caption must align by position with its member. The position alignment is what the platform uses to translate option values — the `OptionMembers` list never changes per locale, the `OptionCaption` list does. A mismatch in count or order produces silent corruption: the option `Released` shows the caption that belongs to `Pending`, and the bug is locale-dependent. + +## Best Practice + +`OptionMembers = Open,Released,Pending;` and `OptionCaption = 'Open,Released,Pending';` — same count, same order. When adding a new member, update both lines in the same commit. + +See sample: `optioncaption-required-and-matches-membercount.good.al`. + +## Anti Pattern + +`OptionMembers = Open,Released,Pending;` with no `OptionCaption` at all (the user sees the raw English members and translation is impossible), or `OptionMembers = Low,Medium,High,Critical;` paired with `OptionCaption = 'Low,Medium,High';` — count mismatch, `Critical` displays as blank or carries the wrong caption depending on platform version. + +See sample: `optioncaption-required-and-matches-membercount.bad.al`. diff --git a/microsoft/knowledge/style/page-name-must-match-source-table.md b/microsoft/knowledge/style/page-name-must-match-source-table.md new file mode 100644 index 0000000..b8706cb --- /dev/null +++ b/microsoft/knowledge/style/page-name-must-match-source-table.md @@ -0,0 +1,24 @@ +--- +bc-version: [all] +domain: style +keywords: [page-name, source-table, misleading, naming] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# A page or view name must describe the table it shows + +## Description + +A page (or filtered page View) whose name references one entity but whose `SourceTable` is a different entity misleads every consumer of the object's metadata. A page named `"Items with Negative Inventory"` that sources `"Stockkeeping Unit"` looks like a list of items in the search bar and in role explorer, but presents stockkeeping-unit fields and behaviour. The fix is either to rename the page to match the source table — `"Stockkeeping Units with Negative Inventory"` — or to change the source table to the entity the name promises. The choice depends on which the actual users are asking for; the constraint is that the two MUST agree. + +The rule extends to filtered Views declared inside a page: the `View` name should describe the filter applied to the page's existing source, not introduce a different entity. + +## Best Practice + +Read the page name out loud and ask: "If a user typed this into the search bar, would they expect to see rows from ``?" If the answer is no, rename one side or the other. The same check applies whenever the source table changes — the name has to follow. + +## Anti Pattern + +`page "Items with Negative Inventory" { SourceTable = "Stockkeeping Unit"; … }`. The Tell-Don't-Ask name asserts items; the source contradicts it. Reviewers should flag every mismatch they spot, even when both sides "make sense individually" — they have to agree. diff --git a/microsoft/knowledge/style/single-space-after-not-operator.bad.al b/microsoft/knowledge/style/single-space-after-not-operator.bad.al new file mode 100644 index 0000000..c7143e7 --- /dev/null +++ b/microsoft/knowledge/style/single-space-after-not-operator.bad.al @@ -0,0 +1,11 @@ +codeunit 50233 "Sample Not Spacing Bad" +{ + procedure Check(): Boolean + var + Customer: Record Customer; + begin + if NOT Customer.IsEmpty() then + exit(true); + exit(false); + end; +} diff --git a/microsoft/knowledge/style/single-space-after-not-operator.good.al b/microsoft/knowledge/style/single-space-after-not-operator.good.al new file mode 100644 index 0000000..92d8f33 --- /dev/null +++ b/microsoft/knowledge/style/single-space-after-not-operator.good.al @@ -0,0 +1,11 @@ +codeunit 50232 "Sample Not Spacing Good" +{ + procedure Check(): Boolean + var + Customer: Record Customer; + begin + if not Customer.IsEmpty() then + exit(true); + exit(false); + end; +} diff --git a/microsoft/knowledge/style/single-space-after-not-operator.md b/microsoft/knowledge/style/single-space-after-not-operator.md new file mode 100644 index 0000000..c5d2077 --- /dev/null +++ b/microsoft/knowledge/style/single-space-after-not-operator.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: style +keywords: [spacing, not, operator, aa0003, codecop] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Exactly one space between `not` and its argument (CodeCop AA0003) + +## Description + +CodeCop AA0003 requires exactly one space between the `not` operator and the expression it negates. `if not Customer.FindFirst() then …` is correct; `if not Customer.FindFirst() then …` (two spaces) and `if notCustomer.FindFirst() then …` (zero — which fails parsing anyway) are not. The rule is also the place where uppercase `NOT` is flagged in combination with CodeCop AA0241 (reserved keywords must be lowercase): `if NOT Condition then` is doubly wrong. + +## Best Practice + +`if not Condition then`, `if not Customer.IsEmpty() then`, `exit(not Result)`. One space, lowercase keyword, no parentheses around the bare boolean. + +See sample: `single-space-after-not-operator.good.al`. + +## Anti Pattern + +`if NOT condition then`, `if not condition then`, `if !condition then` (which is not even AL — `!` is not a negation operator in AL). All three either trip AA0003 / AA0241 or fail to compile. + +See sample: `single-space-after-not-operator.bad.al`. diff --git a/microsoft/knowledge/style/single-space-around-binary-operators.bad.al b/microsoft/knowledge/style/single-space-around-binary-operators.bad.al new file mode 100644 index 0000000..2515d33 --- /dev/null +++ b/microsoft/knowledge/style/single-space-around-binary-operators.bad.al @@ -0,0 +1,12 @@ +codeunit 50229 "Sample Spaces Op Bad" +{ + procedure Compute(Amount: Decimal; Quantity: Decimal): Decimal + var + Price: Decimal; + begin + Price:=Amount*Quantity; + if (Amount>0)and(Quantity>0) then + exit(Price); + exit(0); + end; +} diff --git a/microsoft/knowledge/style/single-space-around-binary-operators.good.al b/microsoft/knowledge/style/single-space-around-binary-operators.good.al new file mode 100644 index 0000000..55793d3 --- /dev/null +++ b/microsoft/knowledge/style/single-space-around-binary-operators.good.al @@ -0,0 +1,12 @@ +codeunit 50228 "Sample Spaces Op Good" +{ + procedure Compute(Amount: Decimal; Quantity: Decimal): Decimal + var + Price: Decimal; + begin + Price := Amount * Quantity; + if (Amount > 0) and (Quantity > 0) then + exit(Price); + exit(0); + end; +} diff --git a/microsoft/knowledge/style/single-space-around-binary-operators.md b/microsoft/knowledge/style/single-space-around-binary-operators.md new file mode 100644 index 0000000..a09fef9 --- /dev/null +++ b/microsoft/knowledge/style/single-space-around-binary-operators.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: style +keywords: [spacing, binary-operator, aa0001, codecop, formatting] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# One space on each side of every binary operator (CodeCop AA0001) + +## Description + +CodeCop AA0001 requires exactly one space on each side of every binary operator: assignment (`:=`), arithmetic (`+`, `-`, `*`, `/`, `mod`, `div`), comparison (`=`, `<>`, `<`, `<=`, `>`, `>=`), logical (`and`, `or`, `xor`), and string concatenation. `x:=1+2`, `Price:=Amount*Quantity`, `if a=b then`, and `if a and b then` all violate the rule. The rule applies to the binary use of `-` (subtraction); the unary minus (`-Profit`) takes no leading space. + +## Best Practice + +Write `x := 1 + 2`, `Price := Amount * Quantity`, `if a = b then`, `if a and b then`. The standard AL formatter inserts these spaces automatically; running `Alt+Shift+F` (Format Document) in the AL extension is the simplest way to bring an entire file into compliance. + +See sample: `single-space-around-binary-operators.good.al`. + +## Anti Pattern + +`x:=1+2;`, `Price:=Amount*Quantity;`, `if a=b then`, `if a and b then`. All trip AA0001. + +See sample: `single-space-around-binary-operators.bad.al`. diff --git a/microsoft/knowledge/style/temporary-variable-temp-prefix.bad.al b/microsoft/knowledge/style/temporary-variable-temp-prefix.bad.al new file mode 100644 index 0000000..62a1dbe --- /dev/null +++ b/microsoft/knowledge/style/temporary-variable-temp-prefix.bad.al @@ -0,0 +1,12 @@ +codeunit 50217 "Sample Temp Prefix Bad" +{ + procedure BuildBuffer(var SalesLine: Record "Sales Line" temporary) + var + WIPBuffer: Record "Job WIP Buffer" temporary; + begin + WIPBuffer.Init(); + WIPBuffer.Insert(); + SalesLine.Init(); + SalesLine.Insert(); + end; +} diff --git a/microsoft/knowledge/style/temporary-variable-temp-prefix.good.al b/microsoft/knowledge/style/temporary-variable-temp-prefix.good.al new file mode 100644 index 0000000..09123a9 --- /dev/null +++ b/microsoft/knowledge/style/temporary-variable-temp-prefix.good.al @@ -0,0 +1,12 @@ +codeunit 50216 "Sample Temp Prefix Good" +{ + procedure BuildBuffer(var TempSalesLine: Record "Sales Line" temporary) + var + TempJobWIPBuffer: Record "Job WIP Buffer" temporary; + begin + TempJobWIPBuffer.Init(); + TempJobWIPBuffer.Insert(); + TempSalesLine.Init(); + TempSalesLine.Insert(); + end; +} diff --git a/microsoft/knowledge/style/temporary-variable-temp-prefix.md b/microsoft/knowledge/style/temporary-variable-temp-prefix.md new file mode 100644 index 0000000..2211b4c --- /dev/null +++ b/microsoft/knowledge/style/temporary-variable-temp-prefix.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: style +keywords: [temporary, temp, prefix, record-variable, naming] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Prefix temporary record variables with `Temp` + +## Description + +A `Record` variable declared with the `temporary` modifier behaves nothing like a normal record variable: it never touches the database, holds rows only for the lifetime of the variable, and is not visible to filters or queries on the underlying table. The BC convention is to make that difference visible at every call site by prefixing the variable name with `Temp` — `TempJobWIPBuffer`, `TempSalesLine`, `TempIntegerBuffer`. The convention is load-bearing for code review: when a reader sees `SalesLine.Insert()`, they expect a database write; when they see `TempSalesLine.Insert()`, they know it is an in-memory buffer. + +## Best Practice + +Every variable of type `Record X temporary` must start with `Temp`. The same applies to parameters: a procedure that receives a temporary record as a buffer names the parameter `TempBuffer`, `TempSalesLine`, and so on. The convention extends naturally to derived names — `TempJobWIPBufferCopy`, `TempSourceSalesLine` — anything that starts with `Temp` is in-memory. + +See sample: `temporary-variable-temp-prefix.good.al`. + +## Anti Pattern + +`WIPBuffer: Record "Job WIP Buffer" temporary;` reads at the call site as if it were a database operation: `WIPBuffer.Insert()` looks identical to a write to the underlying table. The reader has to scroll back to the declaration to discover that this is in-memory, every time. + +See sample: `temporary-variable-temp-prefix.bad.al`. diff --git a/microsoft/knowledge/style/this-keyword-in-codeunits.bad.al b/microsoft/knowledge/style/this-keyword-in-codeunits.bad.al new file mode 100644 index 0000000..7fd03a1 --- /dev/null +++ b/microsoft/knowledge/style/this-keyword-in-codeunits.bad.al @@ -0,0 +1,14 @@ +codeunit 50215 "Sample This Bad" +{ + procedure ProcessRecord(Customer: Record Customer) + var + Helper: Codeunit "Sample This Helper"; + begin + ValidateCustomer(Customer); + Helper.DoWork(); + end; + + local procedure ValidateCustomer(Customer: Record Customer) + begin + end; +} diff --git a/microsoft/knowledge/style/this-keyword-in-codeunits.good.al b/microsoft/knowledge/style/this-keyword-in-codeunits.good.al new file mode 100644 index 0000000..392c042 --- /dev/null +++ b/microsoft/knowledge/style/this-keyword-in-codeunits.good.al @@ -0,0 +1,14 @@ +codeunit 50214 "Sample This Good" +{ + procedure ProcessRecord(Customer: Record Customer) + var + Helper: Codeunit "Sample This Helper"; + begin + this.ValidateCustomer(Customer); + Helper.DoWork(this); + end; + + local procedure ValidateCustomer(Customer: Record Customer) + begin + end; +} diff --git a/microsoft/knowledge/style/this-keyword-in-codeunits.md b/microsoft/knowledge/style/this-keyword-in-codeunits.md new file mode 100644 index 0000000..ffcf5a1 --- /dev/null +++ b/microsoft/knowledge/style/this-keyword-in-codeunits.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: style +keywords: [this, codeunit, self-reference, aa0248, scope] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Use the `this` keyword for self-reference inside codeunits (CodeCop AA0248) + +## Description + +CodeCop AA0248 recommends prefixing self-references inside a codeunit with `this`. `this.ValidateCustomer(Customer)` is unambiguous: the call resolves to a procedure on the current codeunit, not to a local variable or a procedure on a passed-in object. Without the prefix, a reader of a 200-line procedure has to scan the whole codeunit to confirm whether `ValidateCustomer` is local. `this` also makes it possible to pass the current codeunit as an argument — `SomeOtherCodeunit.DoWork(this)` — which is the only way to expose the running codeunit instance to a collaborator. The rule applies only to codeunits, not to pages, reports, queries, or tables — those object types do not have a `this` reference in AL. + +## Best Practice + +Inside a codeunit, prefix calls to procedures and accesses to global variables on the same codeunit with `this.`, and pass `this` when an external codeunit needs a reference to the running instance. + +See sample: `this-keyword-in-codeunits.good.al`. + +## Anti Pattern + +Calling a codeunit-local procedure as a bare identifier (`ValidateCustomer(Customer)`) when other readings are possible. The ambiguity costs reading time on every encounter and grows with codeunit size. + +See sample: `this-keyword-in-codeunits.bad.al`. diff --git a/microsoft/knowledge/style/tooltip-required-on-page-fields.bad.al b/microsoft/knowledge/style/tooltip-required-on-page-fields.bad.al new file mode 100644 index 0000000..e6b356c --- /dev/null +++ b/microsoft/knowledge/style/tooltip-required-on-page-fields.bad.al @@ -0,0 +1,23 @@ +page 50251 "Sample Tooltip Bad" +{ + PageType = Card; + SourceTable = Customer; + layout + { + area(Content) + { + group(General) + { + field("No."; Rec."No.") + { + ApplicationArea = All; + } + field(Amount; Rec."Balance (LCY)") + { + ApplicationArea = All; + ToolTip = ''; + } + } + } + } +} diff --git a/microsoft/knowledge/style/tooltip-required-on-page-fields.good.al b/microsoft/knowledge/style/tooltip-required-on-page-fields.good.al new file mode 100644 index 0000000..1816de5 --- /dev/null +++ b/microsoft/knowledge/style/tooltip-required-on-page-fields.good.al @@ -0,0 +1,24 @@ +page 50250 "Sample Tooltip Good" +{ + PageType = Card; + SourceTable = Customer; + layout + { + area(Content) + { + group(General) + { + field("No."; Rec."No.") + { + ApplicationArea = All; + ToolTip = 'Specifies the number that identifies the customer.'; + } + field(Amount; Rec."Balance (LCY)") + { + ApplicationArea = All; + ToolTip = 'Shows the total balance in local currency.'; + } + } + } + } +} diff --git a/microsoft/knowledge/style/tooltip-required-on-page-fields.md b/microsoft/knowledge/style/tooltip-required-on-page-fields.md new file mode 100644 index 0000000..fc5a3cb --- /dev/null +++ b/microsoft/knowledge/style/tooltip-required-on-page-fields.md @@ -0,0 +1,28 @@ +--- +bc-version: [all] +domain: style +keywords: [tooltip, page-field, aa0218, codecop, accessibility, specifies] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Every page field needs a `ToolTip` (CodeCop AA0218) + +## Description + +CodeCop AA0218 requires a non-empty `ToolTip` property on every field control on a page. The tooltip is what users see on hover and is what screen readers announce; an empty or missing tooltip removes a piece of UI affordance that is part of BC's accessibility baseline. AppSource technical validation rejects pages with missing tooltips. The companion rules AA0219 and AA0220 push the wording further — tooltips should describe what the field shows, conventionally starting with `'Specifies …'`, though `'Shows …'` and similar variants are acceptable when they clearly describe the field's purpose. + +Acceptable exceptions: table fields inside `Upgrade`, `Migration`, `HybridBC14`, `HybridSL`, and `HybridGP` codeunits and tables are allowed to omit the tooltip — those types are not surfaced to users. + +## Best Practice + +Every field control on a regular page carries `ToolTip = 'Specifies …';` (or a clear alternative phrasing). Compose the text in the form "what this value shows" rather than "what the user does with it". + +See sample: `tooltip-required-on-page-fields.good.al`. + +## Anti Pattern + +A field control with no `ToolTip` property at all, or `ToolTip = '';`. AA0218 flags both; the hover state is blank and the screen reader has nothing to announce. + +See sample: `tooltip-required-on-page-fields.bad.al`. diff --git a/microsoft/knowledge/style/variable-declaration-order-by-type.bad.al b/microsoft/knowledge/style/variable-declaration-order-by-type.bad.al new file mode 100644 index 0000000..3131fa6 --- /dev/null +++ b/microsoft/knowledge/style/variable-declaration-order-by-type.bad.al @@ -0,0 +1,13 @@ +codeunit 50247 "Sample Var Order Bad" +{ + procedure Run() + var + CustomerNo: Code[20]; + TempBuffer: Record "Integer" temporary; + Amount: Decimal; + Customer: Record Customer; + IsValid: Boolean; + begin + IsValid := Customer.Get(CustomerNo); + end; +} diff --git a/microsoft/knowledge/style/variable-declaration-order-by-type.good.al b/microsoft/knowledge/style/variable-declaration-order-by-type.good.al new file mode 100644 index 0000000..590ed25 --- /dev/null +++ b/microsoft/knowledge/style/variable-declaration-order-by-type.good.al @@ -0,0 +1,13 @@ +codeunit 50246 "Sample Var Order Good" +{ + procedure Run() + var + Customer: Record Customer; + TempBuffer: Record "Integer" temporary; + CustomerNo: Code[20]; + Amount: Decimal; + IsValid: Boolean; + begin + IsValid := Customer.Get(CustomerNo); + end; +} diff --git a/microsoft/knowledge/style/variable-declaration-order-by-type.md b/microsoft/knowledge/style/variable-declaration-order-by-type.md new file mode 100644 index 0000000..3435726 --- /dev/null +++ b/microsoft/knowledge/style/variable-declaration-order-by-type.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: style +keywords: [variable-declaration, order, var, complex-types, aa0021] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Order variable declarations by type, complex types first (CodeCop AA0021) + +## Description + +CodeCop AA0021 requires that variable declarations inside a `var` block follow a fixed ordering by type, with complex (composite) types appearing before primitive types. The canonical order is `Record`, then `Report`, `Codeunit`, `XmlPort`, `Page`, `Query`, `Notification`, `BigText`, `DateFormula`, `RecordId`, `RecordRef`, `FieldRef`, `FilterPageBuilder`, then the simple types `Text`, `Code`, `Integer`, `Decimal`, `Boolean`, `Date`, `Time`, `DateTime`, `Char`, `Byte`. Inside each type group the variables can be alphabetical or in usage order. Temporary records still sort under `Record`. + +## Best Practice + +Declare all `Record` variables first, then other complex types, then primitives. A consistent order makes diffs review-friendly and matches the convention enforced by the AL formatter and CodeCop. + +See sample: `variable-declaration-order-by-type.good.al`. + +## Anti Pattern + +A `var` block where records and primitives are interleaved — `CustomerNo: Code[20];` between two `Record` variables, or `Amount: Decimal;` declared above the `Customer: Record Customer;` it is computed from. AA0021 flags it and the block is harder to scan; readers expect composite types at the top. + +See sample: `variable-declaration-order-by-type.bad.al`. diff --git a/microsoft/knowledge/style/variable-name-must-not-shadow.bad.al b/microsoft/knowledge/style/variable-name-must-not-shadow.bad.al new file mode 100644 index 0000000..65c8223 --- /dev/null +++ b/microsoft/knowledge/style/variable-name-must-not-shadow.bad.al @@ -0,0 +1,19 @@ +codeunit 50249 "Sample Shadow Bad" +{ + var + Customer: Record Customer; + + procedure ProcessSales() + var + Customer: Text; + Amount: Decimal; + begin + Customer := 'C-100'; + Amount := 0; + end; + + procedure Amount(): Decimal + begin + exit(0); + end; +} diff --git a/microsoft/knowledge/style/variable-name-must-not-shadow.good.al b/microsoft/knowledge/style/variable-name-must-not-shadow.good.al new file mode 100644 index 0000000..f5391ef --- /dev/null +++ b/microsoft/knowledge/style/variable-name-must-not-shadow.good.al @@ -0,0 +1,19 @@ +codeunit 50248 "Sample No Shadow Good" +{ + var + CustomerRec: Record Customer; + + procedure ProcessSales() + var + CustomerName: Text; + SalesAmount: Decimal; + begin + CustomerName := CustomerRec.Name; + SalesAmount := GetAmount(); + end; + + procedure GetAmount(): Decimal + begin + exit(0); + end; +} diff --git a/microsoft/knowledge/style/variable-name-must-not-shadow.md b/microsoft/knowledge/style/variable-name-must-not-shadow.md new file mode 100644 index 0000000..4fee2da --- /dev/null +++ b/microsoft/knowledge/style/variable-name-must-not-shadow.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: style +keywords: [variable-name, shadow, conflict, aa0198, aa0202, aa0204, codecop] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Local variable names must not shadow globals, fields, methods, or actions (CodeCop AA0198/AA0202/AA0204) + +## Description + +Three CodeCop rules — AA0198, AA0202, AA0204 — together forbid a local variable from sharing a name with a global variable on the same object, with a field on the same table or page source, with a procedure on the same object, or with an action on the same page. The compiler resolves the conflict by binding the closer scope, so a local `Customer: Text` will silently override a global `Customer: Record Customer` for the duration of a procedure — every call site reading `Customer.Name` from inside that procedure refers to the text, and the breakage is invisible to a reader who has both declarations on screen. + +## Best Practice + +Differentiate every local declaration from globals, fields, procedures, and actions on the same object. `Customer` global plus `CustomerName` local; method `GetAmount` plus local `SalesAmount`. The standard pattern is to attach a noun suffix to the local (`CustomerName`, `CustomerRec`, `CustomerNo`) rather than to the global. + +See sample: `variable-name-must-not-shadow.good.al`. + +## Anti Pattern + +A procedure that declares a local `Customer: Text` inside a codeunit that already has a global `Customer: Record Customer`. The local wins and the global becomes unreachable inside the procedure. AA0198/AA0202/AA0204 flag this category of conflict whether the colliding entity is a global, a field, a method, or an action. + +See sample: `variable-name-must-not-shadow.bad.al`. diff --git a/microsoft/knowledge/style/xmldoc-for-public-library-procedures.md b/microsoft/knowledge/style/xmldoc-for-public-library-procedures.md new file mode 100644 index 0000000..bffa5e8 --- /dev/null +++ b/microsoft/knowledge/style/xmldoc-for-public-library-procedures.md @@ -0,0 +1,24 @@ +--- +bc-version: [all] +domain: style +keywords: [xmldoc, summary, param, returns, public-procedure, documentation] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Add XML documentation to public procedures on library/API codeunits + +## Description + +XML documentation comments (`/// `, `/// …`, `/// `) are expected on procedures that form the public surface of a library — codeunits intended to be called from outside the current app: System App modules, AppSource library codeunits, `Access = Public` codeunits exposed for extension. The supported tags are ``, ``, ``, ``, ``, and ``. Active wording is preferred — `'Sets…'`, `'Gets…'`, `'Specifies…'` — and the docs should list parameter preconditions and any exceptions the procedure may raise. + +XML docs are NOT required on internal procedures, event subscribers, trigger implementations, page-part procedures, test procedures, or the object declarations themselves (tables, pages, codeunits). The reviewer signal is a `procedure` (not `local procedure`, not `internal procedure`) declared inside a codeunit whose role is "library" — those need XML docs; everything else is optional. + +## Best Practice + +For every public procedure on a library codeunit, write a `` describing what the procedure does, one `` per parameter naming its role and preconditions, and `` describing the return when applicable. Avoid placeholder text — `Validates discount` is no better than no doc at all. + +## Anti Pattern + +A public procedure on a library codeunit with no XML doc, or a `` that restates the procedure name in three words. The first leaves consumers guessing at intent; the second wastes the slot a meaningful description should occupy. diff --git a/microsoft/knowledge/testing/transactionmodel-attribute-governs-test-transactions.bad.al b/microsoft/knowledge/testing/transactionmodel-attribute-governs-test-transactions.bad.al new file mode 100644 index 0000000..c30ae3b --- /dev/null +++ b/microsoft/knowledge/testing/transactionmodel-attribute-governs-test-transactions.bad.al @@ -0,0 +1,10 @@ +codeunit 50154 "Test Sample TransModel Bad" +{ + Subtype = Test; + + [Test] + [TransactionModel(TransactionModel::AutoRollback)] + procedure TestPostingRoutineAutoRollback() + begin + end; +} diff --git a/microsoft/knowledge/testing/transactionmodel-attribute-governs-test-transactions.good.al b/microsoft/knowledge/testing/transactionmodel-attribute-governs-test-transactions.good.al new file mode 100644 index 0000000..f977a94 --- /dev/null +++ b/microsoft/knowledge/testing/transactionmodel-attribute-governs-test-transactions.good.al @@ -0,0 +1,21 @@ +codeunit 50153 "Test Sample TransModel Good" +{ + Subtype = Test; + + [Test] + [TransactionModel(TransactionModel::AutoRollback)] + procedure TestLogicThatDoesNotCommit() + var + Customer: Record Customer; + begin + Customer.Init(); + Customer."No." := 'T-001'; + Customer.Insert(true); + end; + + [Test] + [TransactionModel(TransactionModel::AutoCommit)] + procedure TestLogicThatCommitsInternally() + begin + end; +} diff --git a/microsoft/knowledge/testing/transactionmodel-attribute-governs-test-transactions.md b/microsoft/knowledge/testing/transactionmodel-attribute-governs-test-transactions.md new file mode 100644 index 0000000..084fccd --- /dev/null +++ b/microsoft/knowledge/testing/transactionmodel-attribute-governs-test-transactions.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: testing +keywords: [transactionmodel, attribute, test, autorollback, autocommit, testisolation] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Match TransactionModel to the commit behavior of the code under test + +## Description + +`[TransactionModel(...)]` declares how a test method interacts with the database's write transaction. The attribute applies only to methods inside a codeunit with `SubType = Test` and takes one of three values: `AutoRollback`, `AutoCommit`, or `None`. The choice must match the code being exercised — in particular, whether that code calls `Commit()`. Per the platform reference, "if the code that you test includes calls to the COMMIT Method, then set the TransactionModel property on the test method to AutoCommit." Applying `AutoRollback` to a test that drives code which calls `Commit` produces a runtime error on the first Commit, not a meaningful assertion failure — the test does not complete, and the reviewer sees an infrastructure error instead of a business-logic verdict. + +## Best Practice + +Default to `AutoRollback`: it opens a write transaction at the start of the test, runs the test body, and rolls back at the end, leaving the database in its original state. Pick `AutoCommit` only when the code under test genuinely calls `Commit` — posting routines, job-queue handlers, integration flows — and pair that test's codeunit with a `TestIsolation`-enabled test runner so committed changes are reverted at a higher scope. Pick `None` only for read-only tests or tests that drive UI code without writing from the test method itself, for example tests that validate calculation formulas or read-only projections. + +See sample: `transactionmodel-attribute-governs-test-transactions.good.al`. + +## Anti Pattern + +Applying `AutoRollback` to every test method without checking whether the tested business logic calls `Commit`. The test throws at the first Commit, leaving no verdict on the behavior it intended to verify; in a CI run this looks like a flake or a setup bug, not a specification mismatch. The mirror-image anti-pattern is defaulting to `AutoCommit` across the suite "to avoid the error" — without a `TestIsolation` runner this permanently dirties the test database between runs and produces order-dependent test outcomes. + +See sample: `transactionmodel-attribute-governs-test-transactions.bad.al`. diff --git a/microsoft/knowledge/ui/control-add-in-accessibility-is-developer-responsibility.md b/microsoft/knowledge/ui/control-add-in-accessibility-is-developer-responsibility.md new file mode 100644 index 0000000..cc74054 --- /dev/null +++ b/microsoft/knowledge/ui/control-add-in-accessibility-is-developer-responsibility.md @@ -0,0 +1,20 @@ +--- +bc-version: [all] +domain: ui +keywords: [control-add-in, javascript, accessibility, framework, wcag] +technologies: [al, javascript] +countries: [w1] +application-area: [all] +--- + +# Control add-in accessibility is the developer's responsibility + +## Description + +When a developer builds a JavaScript control add-in, they bypass the Business Central framework's built-in accessibility support and take full responsibility for the accessibility of the rendered HTML, JavaScript, and CSS. Unlike standard AL page controls, an add-in receives no automatic ARIA semantics, no automatic keyboard handling, and no automatic high-contrast support from the BC client. + +Control add-in code must be reviewed for WCAG 2.1 AA compliance and general accessibility best practices. Automated review is inherently non-exhaustive — many accessibility issues (keyboard flow, screen reader announcements, dynamic behavior) require manual testing. + +## Best Practice + +Treat every UI-rendering change to a control add-in as something the platform will not catch for you: accessible names, semantic HTML, keyboard reachability, focus management, contrast, and reflow are all yours to verify. When reporting issues in control add-in code, include a recommendation that a manual accessibility review accompany any control add-in that renders a UI. diff --git a/microsoft/knowledge/ui/control-add-in-has-no-bc-color-tokens.md b/microsoft/knowledge/ui/control-add-in-has-no-bc-color-tokens.md new file mode 100644 index 0000000..ac396e2 --- /dev/null +++ b/microsoft/knowledge/ui/control-add-in-has-no-bc-color-tokens.md @@ -0,0 +1,18 @@ +--- +bc-version: [all] +domain: ui +keywords: [control-add-in, color-tokens, theming, high-contrast, forced-colors, accessibility] +technologies: [al, javascript] +countries: [w1] +application-area: [all] +--- + +# Control add-ins cannot use BC color tokens or theming + +## Description + +A JavaScript control add-in has no access to Business Central's color tokens or theming system. The BC client will not push theme variables, accent colors, or high-contrast palettes into the add-in's iframe. As a result, the add-in must handle Windows contrast themes independently — for example by responding to the `forced-colors` CSS media query or an equivalent mechanism, and by ensuring its own contrast ratios meet WCAG AA (4.5:1 for normal text, 3:1 for large text and UI components) against the backgrounds it draws. + +## Best Practice + +Style control add-ins with explicit colors that are known to meet contrast requirements, and add a `forced-colors` (or equivalent) branch so that Windows high-contrast users see a usable rendering. Do not assume that the add-in inherits BC's theme — verify the rendered output in default, dark, and high-contrast themes. diff --git a/microsoft/knowledge/ui/cosmetic-styles-need-no-textual-context.md b/microsoft/knowledge/ui/cosmetic-styles-need-no-textual-context.md new file mode 100644 index 0000000..02d2f13 --- /dev/null +++ b/microsoft/knowledge/ui/cosmetic-styles-need-no-textual-context.md @@ -0,0 +1,28 @@ +--- +bc-version: [all] +domain: ui +keywords: [style, cosmetic, attention, strong, subordinate, accessibility] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Cosmetic styles need no textual context + +## Description + +A field's `Style` property controls text formatting. Some style values are purely **cosmetic** — they change visual appearance but do not convey semantic meaning. Cosmetic styles never require additional context and must not be reported as accessibility findings: + +- `None`, `Standard` +- `StandardAccent` (Blue) +- `Strong` (Bold), `StrongAccent` (Blue + Bold) +- `Attention` (Red + Italic), `AttentionAccent` (Blue + Italic) +- `Subordinate` (Grey) + +This list is exhaustive — every other named style on the platform either falls outside the cosmetic set or is one of the three semantic styles documented in `semantic-styles-need-independent-textual-meaning.md`. + +The same rule applies whether the cosmetic style is set via `Style` directly or via a `StyleExpr` Text variable. If the resolved value at runtime is one of the cosmetic styles above, the field is safe. + +## Best Practice + +Use cosmetic styles freely for visual emphasis. Do not treat the use of `Attention`, `Strong`, or any other cosmetic value as an accessibility issue — the colors and weights are purely presentational and carry no meaning a screen reader needs to convey. diff --git a/microsoft/knowledge/ui/grid-data-table-heuristic.good.al b/microsoft/knowledge/ui/grid-data-table-heuristic.good.al new file mode 100644 index 0000000..21a3851 --- /dev/null +++ b/microsoft/knowledge/ui/grid-data-table-heuristic.good.al @@ -0,0 +1,34 @@ +page 50207 "UI Sample Data Table" +{ + PageType = Card; + SourceTable = Customer; + + layout + { + area(Content) + { + grid(DataGrid) + { + GridLayout = Columns; + group(Column1) + { + ShowCaption = false; + field(Name; Rec.Name) + { + ApplicationArea = All; + ShowCaption = false; + } + } + group(Column2) + { + ShowCaption = false; + field(Balance; Rec."Balance (LCY)") + { + ApplicationArea = All; + ShowCaption = false; + } + } + } + } + } +} diff --git a/microsoft/knowledge/ui/grid-data-table-heuristic.md b/microsoft/knowledge/ui/grid-data-table-heuristic.md new file mode 100644 index 0000000..2cd6b63 --- /dev/null +++ b/microsoft/knowledge/ui/grid-data-table-heuristic.md @@ -0,0 +1,28 @@ +--- +bc-version: [all] +domain: ui +keywords: [grid, fixed, data-table, heuristic, show-caption, accessibility] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Grid and fixed-layout data-table heuristic + +## Description + +Business Central renders `grid()` and `fixed()` layouts in two modes. The mode is chosen automatically by a client heuristic. A grid renders as a **data table** (HTML `` with row/column semantics) only when **all** of the following are true: + +- All direct children of the grid/fixed are groups (no loose fields). +- Every child of every group is a field (no nested groups or other controls). +- All fields have `ShowCaption = false`. + +The heuristic checks field captions only — group `ShowCaption` is not part of the check. A group with a visible caption inside a data-table grid does **not** break the heuristic and is not a violation. However, groups in a data table should also have `ShowCaption = false` for correct visual presentation. + +Any grid or fixed layout that does not meet all three conditions renders as a layout table (visual column arrangement, no table semantics). + +## Best Practice + +If you intend a grid or fixed layout to render as a data table, satisfy all three conditions and verify the resulting markup matches your intent. If you do not need tabular semantics, prefer simple groups over grid or fixed layouts — they reflow better and produce correct semantic markup automatically. + +See sample: `grid-data-table-heuristic.good.al`. diff --git a/microsoft/knowledge/ui/group-caption-quality-is-not-an-accessibility-issue.md b/microsoft/knowledge/ui/group-caption-quality-is-not-an-accessibility-issue.md new file mode 100644 index 0000000..7c546b8 --- /dev/null +++ b/microsoft/knowledge/ui/group-caption-quality-is-not-an-accessibility-issue.md @@ -0,0 +1,20 @@ +--- +bc-version: [all] +domain: ui +keywords: [group, caption, missing, duplicate, generic, accessibility, false-positive] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Group caption quality is not an accessibility issue + +## Description + +Group captions affect page organization, but missing, generic, or duplicate group captions are **not** accessibility violations per the BC accessibility rules. Do not flag groups for missing, generic, or duplicate captions during an accessibility review. + +This rule prevents a common false positive: LLM-driven reviewers tend to flag "GroupName" or duplicated `Caption = 'General'` as accessibility issues, but the BC client does not depend on group captions for screen-reader announcements of the fields within. Caption quality belongs to other review domains (UI text / style), not accessibility. + +## Best Practice + +Treat group caption quality as a UI-text concern reviewed elsewhere. Accessibility findings on groups should be limited to the specific patterns documented in the `grid-data-table-heuristic.md`, `tabular-intent-requires-data-table-conditions.md`, and `group-labeled-first-child-exception.md` files. diff --git a/microsoft/knowledge/ui/group-labeled-first-child-exception.bad.al b/microsoft/knowledge/ui/group-labeled-first-child-exception.bad.al new file mode 100644 index 0000000..648e729 --- /dev/null +++ b/microsoft/knowledge/ui/group-labeled-first-child-exception.bad.al @@ -0,0 +1,22 @@ +page 50204 "UI Sample First Child Bad" +{ + PageType = Card; + SourceTable = Customer; + + layout + { + area(Content) + { + group(SomeGroup) + { + ShowCaption = false; + field(DescriptionField; Rec.Address) + { + ApplicationArea = All; + ShowCaption = false; + MultiLine = true; + } + } + } + } +} diff --git a/microsoft/knowledge/ui/group-labeled-first-child-exception.good.al b/microsoft/knowledge/ui/group-labeled-first-child-exception.good.al new file mode 100644 index 0000000..3852237 --- /dev/null +++ b/microsoft/knowledge/ui/group-labeled-first-child-exception.good.al @@ -0,0 +1,22 @@ +page 50203 "UI Sample First Child Good" +{ + PageType = Card; + SourceTable = Customer; + + layout + { + area(Content) + { + group(Description) + { + Caption = 'Description'; + field(DescriptionField; Rec.Address) + { + ApplicationArea = All; + ShowCaption = false; + MultiLine = true; + } + } + } + } +} diff --git a/microsoft/knowledge/ui/group-labeled-first-child-exception.md b/microsoft/knowledge/ui/group-labeled-first-child-exception.md new file mode 100644 index 0000000..2f47ae3 --- /dev/null +++ b/microsoft/knowledge/ui/group-labeled-first-child-exception.md @@ -0,0 +1,32 @@ +--- +bc-version: [all] +domain: ui +keywords: [show-caption, group, first-child, multiline, accessibility] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Group-labeled first child exception + +## Description + +`ShowCaption = false` is acceptable on an editable field only when **all** of the following conditions are met: + +1. The control is the **first visible field** in its parent group. +2. The field has `ShowCaption = false`. +3. The parent group has a visible caption: `ShowCaption` is true (the default) **and** the group has a non-empty `Caption` value. + +When these three conditions hold, the group caption becomes the accessible label for the field. This works regardless of whether the field is multiline. The presence of `InstructionalText` on the field is irrelevant to this check. + +## Best Practice + +Do not second-guess this exception. If the three conditions are met, the pattern is acceptable — even if the group caption seems generic (e.g. "General Information") or does not exactly match the field name. + +See sample: `group-labeled-first-child-exception.good.al`. + +## Anti Pattern + +If the parent group has `ShowCaption = false` or no `Caption`, the first-child exception does not apply: the field has no accessible label anywhere. + +See sample: `group-labeled-first-child-exception.bad.al`. diff --git a/microsoft/knowledge/ui/group-show-caption-false-outside-grid-is-not-a-violation.md b/microsoft/knowledge/ui/group-show-caption-false-outside-grid-is-not-a-violation.md new file mode 100644 index 0000000..fba4bfd --- /dev/null +++ b/microsoft/knowledge/ui/group-show-caption-false-outside-grid-is-not-a-violation.md @@ -0,0 +1,20 @@ +--- +bc-version: [all] +domain: ui +keywords: [group, show-caption, card, document, layout, accessibility, false-positive] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Group ShowCaption = false outside grid/fixed is a layout choice + +## Description + +In a standard Card or Document page, a group with `ShowCaption = false` is a layout choice, not an accessibility violation. Only flag `ShowCaption` issues as documented in the grid/fixed-layout and field-level `ShowCaption` rules — `show-caption-on-editable-fields.md`, `grid-data-table-heuristic.md`, `tabular-intent-requires-data-table-conditions.md`. + +The heuristic in BC's client uses **field** captions to decide between data-table and layout-table rendering. A captionless group (outside a grid or fixed layout) does not strip labels from its child fields — each field retains its own caption. + +## Best Practice + +Reserve accessibility findings for hidden **field** labels and grid-semantics problems. Do not raise a finding merely because a `group` block has `ShowCaption = false` in an ordinary Card or Document page layout. diff --git a/microsoft/knowledge/ui/layout-table-with-captions-is-valid.md b/microsoft/knowledge/ui/layout-table-with-captions-is-valid.md new file mode 100644 index 0000000..53caaec --- /dev/null +++ b/microsoft/knowledge/ui/layout-table-with-captions-is-valid.md @@ -0,0 +1,20 @@ +--- +bc-version: [all] +domain: ui +keywords: [grid, fixed, layout-table, show-caption, false-positive, accessibility] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Layout-table grids with visible captions are valid + +## Description + +A grid or fixed layout that does not meet all three data-table conditions renders as a **layout table**. A layout table where editable fields keep their visible captions is not an accessibility violation. Each field is labeled by its own caption — this is a valid, accessible pattern. + +Do not flag a grid or fixed layout as an accessibility issue merely because it does not meet the data-table heuristic. The violation is hidden labels in a non-data-table grid, not the layout choice itself. + +## Best Practice + +When reviewing a grid or fixed layout, first check whether it meets all data-table conditions. If yes, `ShowCaption = false` on fields is correct. If no, allow editable fields to keep their captions and only flag the cases enumerated in `tabular-intent-requires-data-table-conditions.md`. diff --git a/microsoft/knowledge/ui/no-nested-grids.bad.al b/microsoft/knowledge/ui/no-nested-grids.bad.al new file mode 100644 index 0000000..03c4135 --- /dev/null +++ b/microsoft/knowledge/ui/no-nested-grids.bad.al @@ -0,0 +1,33 @@ +page 50210 "UI Sample Nested Grid Bad" +{ + PageType = Card; + SourceTable = Customer; + + layout + { + area(Content) + { + grid(OuterGrid) + { + GridLayout = Columns; + group(Left) + { + ShowCaption = false; + grid(InnerGrid) + { + GridLayout = Rows; + group(Row1) + { + ShowCaption = false; + field(Name; Rec.Name) + { + ApplicationArea = All; + ShowCaption = false; + } + } + } + } + } + } + } +} diff --git a/microsoft/knowledge/ui/no-nested-grids.md b/microsoft/knowledge/ui/no-nested-grids.md new file mode 100644 index 0000000..9de7ba4 --- /dev/null +++ b/microsoft/knowledge/ui/no-nested-grids.md @@ -0,0 +1,22 @@ +--- +bc-version: [all] +domain: ui +keywords: [grid, nested-grid, fixed, data-table, accessibility] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Nested grids are not supported + +## Description + +A grid nested inside another grid is not a supported pattern in Business Central. Even if an inner grid independently meets the data-table heuristic, the outer grid fails because its groups contain non-field children (the inner grids). The result is broken table semantics for both layers. + +Always flag a nested grid as a violation. The fix is to restructure the page so there is at most one grid in any branch of the layout tree, choosing either a data-table or a layout-table arrangement. + +## Anti Pattern + +Wrapping a working data-table grid inside another grid in an attempt to compose two tabular regions side by side. The outer grid silently degrades to layout-table rendering, the inner grid's headers are no longer associated with the outer structure, and editable fields with `ShowCaption = false` lose their labels. + +See sample: `no-nested-grids.bad.al`. diff --git a/microsoft/knowledge/ui/on-drill-down-on-non-editable-fields-renders-as-link.md b/microsoft/knowledge/ui/on-drill-down-on-non-editable-fields-renders-as-link.md new file mode 100644 index 0000000..c27503d --- /dev/null +++ b/microsoft/knowledge/ui/on-drill-down-on-non-editable-fields-renders-as-link.md @@ -0,0 +1,20 @@ +--- +bc-version: [all] +domain: ui +keywords: [on-drill-down, link, non-editable, accessibility, false-positive] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# OnDrillDown on non-editable fields renders as a link + +## Description + +The Business Central client renders non-editable fields that have an `OnDrillDown` trigger as HTML `` (anchor) elements. Screen readers correctly announce these as links. `OnDrillDown` on a non-editable field is therefore **not** an accessibility issue — the platform handles the semantics. + +Do not flag `OnDrillDown` usage as an accessibility issue. The combination of `Editable = false` and `OnDrillDown` is the standard BC pattern for navigable, screen-reader-friendly value cells in list and card pages. + +## Best Practice + +Use `OnDrillDown` freely on non-editable fields when you want users to navigate from a value to a related record or detail page. No additional ARIA attributes or accessible-name workarounds are required. diff --git a/microsoft/knowledge/ui/semantic-style-in-cuegroup-exception.good.al b/microsoft/knowledge/ui/semantic-style-in-cuegroup-exception.good.al new file mode 100644 index 0000000..5471632 --- /dev/null +++ b/microsoft/knowledge/ui/semantic-style-in-cuegroup-exception.good.al @@ -0,0 +1,31 @@ +page 50213 "UI Sample CueGroup Style" +{ + PageType = RoleCenter; + + layout + { + area(RoleCenter) + { + cuegroup(Activities) + { + Caption = 'Activities'; + field(OverdueInvoices; OverdueInvoiceCount) + { + ApplicationArea = All; + Caption = 'Overdue Invoices'; + Style = Unfavorable; + } + field(PaidInvoices; PaidInvoiceCount) + { + ApplicationArea = All; + Caption = 'Paid Invoices'; + Style = Favorable; + } + } + } + } + + var + OverdueInvoiceCount: Integer; + PaidInvoiceCount: Integer; +} diff --git a/microsoft/knowledge/ui/semantic-style-in-cuegroup-exception.md b/microsoft/knowledge/ui/semantic-style-in-cuegroup-exception.md new file mode 100644 index 0000000..5f26333 --- /dev/null +++ b/microsoft/knowledge/ui/semantic-style-in-cuegroup-exception.md @@ -0,0 +1,22 @@ +--- +bc-version: [all] +domain: ui +keywords: [style, cuegroup, cue-tile, favorable, unfavorable, accessibility] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Semantic styles in a cuegroup are auto-labeled + +## Description + +Fields inside a `cuegroup` render as cue tiles. The Business Central client automatically provides an accessible label for semantic styles on cue tiles (for example, "Favorable", "Unfavorable"). Semantic styles in a `cuegroup` therefore do **not** need additional context and should be ignored when checking that semantic colors are backed by text. + +This is a narrow platform exception to `semantic-styles-need-independent-textual-meaning.md`. Outside a `cuegroup`, the normal rule applies. + +## Best Practice + +You may apply `Favorable`, `Unfavorable`, or `Ambiguous` to fields inside a `cuegroup` without supplying a redundant textual indicator — the platform supplies the screen-reader text. Reserve this shortcut for cue tiles only; do not extend it to other layout containers. + +See sample: `semantic-style-in-cuegroup-exception.good.al`. diff --git a/microsoft/knowledge/ui/semantic-styles-need-independent-textual-meaning.bad.al b/microsoft/knowledge/ui/semantic-styles-need-independent-textual-meaning.bad.al new file mode 100644 index 0000000..4741a7c --- /dev/null +++ b/microsoft/knowledge/ui/semantic-styles-need-independent-textual-meaning.bad.al @@ -0,0 +1,27 @@ +page 50212 "UI Sample Semantic Style Bad" +{ + PageType = Card; + SourceTable = Customer; + + layout + { + area(Content) + { + field(CompanyName; Rec.Name) + { + ApplicationArea = All; + Style = Favorable; + } + field(Confidence; ConfidencePercent) + { + ApplicationArea = All; + Caption = 'Confidence'; + StyleExpr = ConfidenceStyle; + } + } + } + + var + ConfidencePercent: Decimal; + ConfidenceStyle: Text; +} diff --git a/microsoft/knowledge/ui/semantic-styles-need-independent-textual-meaning.good.al b/microsoft/knowledge/ui/semantic-styles-need-independent-textual-meaning.good.al new file mode 100644 index 0000000..a42cc08 --- /dev/null +++ b/microsoft/knowledge/ui/semantic-styles-need-independent-textual-meaning.good.al @@ -0,0 +1,28 @@ +page 50211 "UI Sample Semantic Style Good" +{ + PageType = Card; + SourceTable = "Cust. Ledger Entry"; + + layout + { + area(Content) + { + field(OverdueAmount; Rec."Remaining Amount") + { + ApplicationArea = All; + Caption = 'Overdue Amount'; + Style = Unfavorable; + } + field(ProfitMargin; Rec.Amount) + { + ApplicationArea = All; + Caption = 'Profit Margin'; + Style = Favorable; + StyleExpr = IsProfitable; + } + } + } + + var + IsProfitable: Boolean; +} diff --git a/microsoft/knowledge/ui/semantic-styles-need-independent-textual-meaning.md b/microsoft/knowledge/ui/semantic-styles-need-independent-textual-meaning.md new file mode 100644 index 0000000..9d58d41 --- /dev/null +++ b/microsoft/knowledge/ui/semantic-styles-need-independent-textual-meaning.md @@ -0,0 +1,34 @@ +--- +bc-version: [all] +domain: ui +keywords: [style, favorable, unfavorable, ambiguous, color, accessibility] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Semantic styles need independent textual meaning + +## Description + +Three `Style` values carry semantic meaning through color and must be backed by text that conveys the same meaning: + +- `Favorable` (Bold + Green) — implies a positive outcome. +- `Unfavorable` (Bold + Italic + Red) — implies a negative outcome. +- `Ambiguous` (Yellow) — implies an uncertain or mixed outcome. + +For accessibility, assume the style is completely invisible to the user. The semantic meaning must be independently determinable from at least one of: + +1. The **field caption** matches the semantic meaning (e.g. caption "Error" with `Style = Unfavorable`, or "Profit" with `Style = Favorable`). +2. The **field value** communicates the meaning (e.g. value "Success!" with Favorable, a negative number with Unfavorable). +3. An **adjacent field** provides a textual representation of the semantic meaning (e.g. a "Status" column reads "High" / "Medium" / "Low" alongside a percentage field). + +The rule applies equally whether `Style` is set to a literal value or to a variable that evaluates to a semantic style at runtime. + +## Best Practice + +When you reach for `Favorable`, `Unfavorable`, or `Ambiguous`, verify that the caption, value, or an adjacent column already conveys the same meaning. See sample: `semantic-styles-need-independent-textual-meaning.good.al`. + +## Anti Pattern + +Applying a semantic style for purely cosmetic emphasis (e.g. green company name for aesthetics), or using semantic colors where only the color reveals the threshold (e.g. confidence percentages with no qualitative label). See sample: `semantic-styles-need-independent-textual-meaning.bad.al`. diff --git a/microsoft/knowledge/ui/show-caption-false-allowed-on-non-editable-fields.good.al b/microsoft/knowledge/ui/show-caption-false-allowed-on-non-editable-fields.good.al new file mode 100644 index 0000000..16b5300 --- /dev/null +++ b/microsoft/knowledge/ui/show-caption-false-allowed-on-non-editable-fields.good.al @@ -0,0 +1,18 @@ +page 50202 "UI Sample NonEditable Caption" +{ + PageType = Card; + SourceTable = Customer; + + layout + { + area(Content) + { + field("Customer Name"; Rec.Name) + { + ApplicationArea = All; + Editable = false; + ShowCaption = false; + } + } + } +} diff --git a/microsoft/knowledge/ui/show-caption-false-allowed-on-non-editable-fields.md b/microsoft/knowledge/ui/show-caption-false-allowed-on-non-editable-fields.md new file mode 100644 index 0000000..dcf4d95 --- /dev/null +++ b/microsoft/knowledge/ui/show-caption-false-allowed-on-non-editable-fields.md @@ -0,0 +1,22 @@ +--- +bc-version: [all] +domain: ui +keywords: [show-caption, non-editable, content, accessibility] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# ShowCaption = false on non-editable fields + +## Description + +When a field is explicitly non-editable (`Editable = false`), it serves as content rather than as a form field. In that case, `ShowCaption = false` is acceptable: there is no input control whose label could be lost. The combination signals to a reviewer (and to the platform) that the field displays a value standalone — for example a status message or a description that is meaningful on its own. + +This exception does **not** extend to dynamically editable fields. A field with `Editable = SomeBooleanExpression` may be editable at runtime and must keep its caption. + +## Best Practice + +If you want to hide a field's caption, pair `ShowCaption = false` with a literal `Editable = false`. Use this pattern only for content fields that do not act as labels for other fields in the same layout container. + +See sample: `show-caption-false-allowed-on-non-editable-fields.good.al`. diff --git a/microsoft/knowledge/ui/show-caption-in-promptdialog-prompt-area.good.al b/microsoft/knowledge/ui/show-caption-in-promptdialog-prompt-area.good.al new file mode 100644 index 0000000..082ed47 --- /dev/null +++ b/microsoft/knowledge/ui/show-caption-in-promptdialog-prompt-area.good.al @@ -0,0 +1,31 @@ +page 50206 "UI Sample PromptDialog" +{ + PageType = PromptDialog; + Caption = 'Draft new project with Copilot'; + + layout + { + area(Prompt) + { + field(ProjectDescription; InputProjectDescription) + { + ApplicationArea = All; + ShowCaption = false; + MultiLine = true; + InstructionalText = 'Describe the project'; + } + } + area(Content) + { + field("Job Description"; JobDescription) + { + ApplicationArea = All; + Caption = 'Project Description'; + } + } + } + + var + InputProjectDescription: Text; + JobDescription: Text; +} diff --git a/microsoft/knowledge/ui/show-caption-in-promptdialog-prompt-area.md b/microsoft/knowledge/ui/show-caption-in-promptdialog-prompt-area.md new file mode 100644 index 0000000..9b2e43b --- /dev/null +++ b/microsoft/knowledge/ui/show-caption-in-promptdialog-prompt-area.md @@ -0,0 +1,22 @@ +--- +bc-version: [all] +domain: ui +keywords: [show-caption, promptdialog, copilot, prompt, accessibility] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# ShowCaption in a PromptDialog prompt area + +## Description + +On `PageType = PromptDialog` pages, input fields inside `area(Prompt)` are labeled by the dialog's heading — the page `Caption`. Setting `ShowCaption = false` on such an input field is the standard pattern and should not be flagged, provided the page has a `Caption`. + +Fields in the `area(Content)` section of the same PromptDialog page are **not** labeled by the dialog heading and follow the normal `ShowCaption` rules. + +## Best Practice + +In a PromptDialog, give the page a meaningful `Caption` (the dialog heading) and let prompt-area input fields hide their own captions. Treat content-area fields like any other editable field — keep their captions. + +See sample: `show-caption-in-promptdialog-prompt-area.good.al`. diff --git a/microsoft/knowledge/ui/show-caption-in-repeater-allowed.good.al b/microsoft/knowledge/ui/show-caption-in-repeater-allowed.good.al new file mode 100644 index 0000000..c463e71 --- /dev/null +++ b/microsoft/knowledge/ui/show-caption-in-repeater-allowed.good.al @@ -0,0 +1,25 @@ +page 50205 "UI Sample Repeater" +{ + PageType = List; + SourceTable = "Sales Line"; + + layout + { + area(Content) + { + repeater(Lines) + { + field(Description; Rec.Description) + { + ApplicationArea = All; + ShowCaption = false; + } + field(Amount; Rec.Amount) + { + ApplicationArea = All; + ShowCaption = false; + } + } + } + } +} diff --git a/microsoft/knowledge/ui/show-caption-in-repeater-allowed.md b/microsoft/knowledge/ui/show-caption-in-repeater-allowed.md new file mode 100644 index 0000000..b161149 --- /dev/null +++ b/microsoft/knowledge/ui/show-caption-in-repeater-allowed.md @@ -0,0 +1,22 @@ +--- +bc-version: [all] +domain: ui +keywords: [show-caption, repeater, column-header, accessibility] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# ShowCaption inside a repeater is harmless + +## Description + +Fields inside a `repeater()` control are labeled by their **column headers**, not by their own captions. `ShowCaption = false` on a field inside a repeater is harmless and should not be flagged. + +This is the explicit behaviour of the Business Central client: a repeater renders as a tabular list whose column headings come from each field's `Caption` (or source-table caption), and individual row cells do not announce a per-cell caption. + +## Best Practice + +Inside a repeater, you may set `ShowCaption = false` on fields without losing accessibility. The column header still provides the label for every cell in that column. Outside a repeater, the rules in `show-caption-on-editable-fields.md` apply. + +See sample: `show-caption-in-repeater-allowed.good.al`. diff --git a/microsoft/knowledge/ui/show-caption-on-editable-fields.bad.al b/microsoft/knowledge/ui/show-caption-on-editable-fields.bad.al new file mode 100644 index 0000000..ea39356 --- /dev/null +++ b/microsoft/knowledge/ui/show-caption-on-editable-fields.bad.al @@ -0,0 +1,27 @@ +page 50201 "UI Sample Editable Caption Bad" +{ + PageType = Card; + SourceTable = Customer; + + layout + { + area(Content) + { + field("Customer Name"; Rec.Name) + { + ApplicationArea = All; + ShowCaption = false; + InstructionalText = 'Enter the customer name'; + } + field("Dynamic Editable"; Rec."No.") + { + ApplicationArea = All; + Editable = IsEditable; + ShowCaption = false; + } + } + } + + var + IsEditable: Boolean; +} diff --git a/microsoft/knowledge/ui/show-caption-on-editable-fields.good.al b/microsoft/knowledge/ui/show-caption-on-editable-fields.good.al new file mode 100644 index 0000000..9030dd4 --- /dev/null +++ b/microsoft/knowledge/ui/show-caption-on-editable-fields.good.al @@ -0,0 +1,16 @@ +page 50200 "UI Sample Editable Caption Good" +{ + PageType = Card; + SourceTable = Customer; + + layout + { + area(Content) + { + field("Customer Name"; Rec.Name) + { + ApplicationArea = All; + } + } + } +} diff --git a/microsoft/knowledge/ui/show-caption-on-editable-fields.md b/microsoft/knowledge/ui/show-caption-on-editable-fields.md new file mode 100644 index 0000000..23075fd --- /dev/null +++ b/microsoft/knowledge/ui/show-caption-on-editable-fields.md @@ -0,0 +1,28 @@ +--- +bc-version: [all] +domain: ui +keywords: [show-caption, editable, accessibility, label, instructional-text] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# ShowCaption on editable fields + +## Description + +`ShowCaption` must remain true (the default) on editable fields unless the field matches one of the officially supported "magic patterns". Fields are editable by default. Setting `ShowCaption = false` on an editable field is almost always an accessibility bug: without a visible caption, screen reader users lose the label that identifies the field, and sighted users lose a visual cue. + +A field whose `Editable` property is a Boolean expression (e.g. `Editable = IsEditable`) is dynamically editable and must be treated as a form field — `ShowCaption = false` on such a field is also a violation. + +## Best Practice + +Leave `ShowCaption` at its default on editable fields. If a caption would be visually redundant, rely on one of the documented magic patterns (group-labeled first child, repeater column, PromptDialog prompt input) rather than removing the caption. + +See sample: `show-caption-on-editable-fields.good.al`. + +## Anti Pattern + +The `InstructionalText` property on a field renders as HTML placeholder text and is **not** a substitute for a caption — it disappears once the user types and is not reliably announced by screen readers. + +See sample: `show-caption-on-editable-fields.bad.al`. diff --git a/microsoft/knowledge/ui/standalone-content-in-layout-table.good.al b/microsoft/knowledge/ui/standalone-content-in-layout-table.good.al new file mode 100644 index 0000000..70f5213 --- /dev/null +++ b/microsoft/knowledge/ui/standalone-content-in-layout-table.good.al @@ -0,0 +1,39 @@ +page 50208 "UI Sample Standalone Content" +{ + PageType = Card; + SourceTable = Customer; + + layout + { + area(Content) + { + grid(InfoGrid) + { + GridLayout = Columns; + group(LeftColumn) + { + field(Address; Rec.Address) + { + ApplicationArea = All; + } + field(City; Rec.City) + { + ApplicationArea = All; + } + } + group(RightColumn) + { + field(StatusMessage; StatusText) + { + ApplicationArea = All; + Editable = false; + ShowCaption = false; + } + } + } + } + } + + var + StatusText: Text; +} diff --git a/microsoft/knowledge/ui/standalone-content-in-layout-table.md b/microsoft/knowledge/ui/standalone-content-in-layout-table.md new file mode 100644 index 0000000..74a69b2 --- /dev/null +++ b/microsoft/knowledge/ui/standalone-content-in-layout-table.md @@ -0,0 +1,22 @@ +--- +bc-version: [all] +domain: ui +keywords: [grid, fixed, layout-table, standalone-content, show-caption, accessibility] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Standalone content in a layout-table grid + +## Description + +A non-editable field with `ShowCaption = false` is acceptable inside a layout-table grid **only when** the field is **standalone content** — it displays a value that is meaningful on its own (for example a status message or a description) and is **not** intended to label or be labeled by another field in the grid. + +Layout tables have no `
` column headers, so a captionless field that is meant to participate in a tabular relationship with a neighbour has no accessible label at all. + +## Best Practice + +Reserve `ShowCaption = false` in a layout-table grid for non-editable, free-standing content cells. If a field's role is to label or annotate another field in the same grid, restructure the grid to meet the data-table conditions (see `grid-data-table-heuristic.md`) instead of hiding the caption. + +See sample: `standalone-content-in-layout-table.good.al`. diff --git a/microsoft/knowledge/ui/style-expr-text-vs-boolean.good.al b/microsoft/knowledge/ui/style-expr-text-vs-boolean.good.al new file mode 100644 index 0000000..3d09c2a --- /dev/null +++ b/microsoft/knowledge/ui/style-expr-text-vs-boolean.good.al @@ -0,0 +1,41 @@ +page 50214 "UI Sample StyleExpr" +{ + PageType = List; + SourceTable = "Sales Header"; + + layout + { + area(Content) + { + repeater(Lines) + { + field(Status; Rec.Status) + { + ApplicationArea = All; + StyleExpr = StatusStyle; + } + field(Amount; Rec.Amount) + { + ApplicationArea = All; + Style = Favorable; + StyleExpr = IsProfitable; + } + } + } + } + + trigger OnAfterGetRecord() + begin + case Rec.Status of + Rec.Status::Open: + StatusStyle := 'Standard'; + Rec.Status::Released: + StatusStyle := 'Favorable'; + end; + IsProfitable := Rec.Amount > 0; + end; + + var + StatusStyle: Text; + IsProfitable: Boolean; +} diff --git a/microsoft/knowledge/ui/style-expr-text-vs-boolean.md b/microsoft/knowledge/ui/style-expr-text-vs-boolean.md new file mode 100644 index 0000000..5040099 --- /dev/null +++ b/microsoft/knowledge/ui/style-expr-text-vs-boolean.md @@ -0,0 +1,25 @@ +--- +bc-version: [all] +domain: ui +keywords: [style-expr, style, boolean, text-variable, accessibility] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# StyleExpr: Boolean toggle vs Text variable + +## Description + +`StyleExpr` on a page field serves two distinct purposes depending on its type: + +- **Boolean** — When `StyleExpr` is a Boolean expression, it controls whether the `Style` property is applied. In this case the `Style` property carries the style name; analyze `Style` and ignore `StyleExpr` itself. +- **Text** — When `StyleExpr` is a Text variable (e.g. `StyleExpr = StatusStyle` where `StatusStyle: Text` and is assigned literals such as `'Favorable'`), the variable contains the style name at runtime. There may be no `Style` property at all — the `StyleExpr` variable **is** the style. + +When `StyleExpr` is Text, you must trace the variable's assignments — typically in `OnAfterGetRecord` or `OnAfterGetCurrRecord` — to determine which styles can be applied, then apply the same accessibility rules as for a literal `Style` value. + +## Best Practice + +Inspect the declared type of the symbol referenced by `StyleExpr` before drawing conclusions. If it is Boolean, evaluate the `Style` property. If it is Text, follow every assignment to the variable and check the full set of possible style values against `cosmetic-styles-need-no-textual-context.md` and `semantic-styles-need-independent-textual-meaning.md`. + +See sample: `style-expr-text-vs-boolean.good.al`. diff --git a/microsoft/knowledge/ui/tabular-intent-requires-data-table-conditions.bad.al b/microsoft/knowledge/ui/tabular-intent-requires-data-table-conditions.bad.al new file mode 100644 index 0000000..3fc52ec --- /dev/null +++ b/microsoft/knowledge/ui/tabular-intent-requires-data-table-conditions.bad.al @@ -0,0 +1,40 @@ +page 50209 "UI Sample Tabular Mix Bad" +{ + PageType = Card; + SourceTable = "Cust. Ledger Entry"; + + layout + { + area(Content) + { + grid(StatementGrid) + { + GridLayout = Columns; + group(Periods) + { + ShowCaption = false; + field(StatementPeriod; Rec."Posting Date") + { + ApplicationArea = All; + Editable = false; + ShowCaption = false; + } + } + group(Balances) + { + ShowCaption = false; + field(StatementBalance; Rec.Amount) + { + ApplicationArea = All; + Editable = false; + ShowCaption = false; + } + field(DueDate; Rec."Due Date") + { + ApplicationArea = All; + } + } + } + } + } +} diff --git a/microsoft/knowledge/ui/tabular-intent-requires-data-table-conditions.md b/microsoft/knowledge/ui/tabular-intent-requires-data-table-conditions.md new file mode 100644 index 0000000..b56aadb --- /dev/null +++ b/microsoft/knowledge/ui/tabular-intent-requires-data-table-conditions.md @@ -0,0 +1,27 @@ +--- +bc-version: [all] +domain: ui +keywords: [grid, fixed, tabular-intent, data-table, accidental-mix, accessibility] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Tabular intent requires data-table conditions + +## Description + +The most common accessibility bug in grid layouts is partially following the data-table conventions. A developer arranges fields with **tabular intent** — one field acts as a label or row header for another — but the grid does not satisfy all the data-table heuristic conditions. The client falls back to layout-table rendering, and the tabular relationships between fields are lost: a screen reader announces each field independently with no programmatic association. + +Flag a grid as an accessibility issue when any of these are true: + +- An editable field has `ShowCaption = false` and the grid does not meet all data-table conditions. +- Fields are arranged so that one field is clearly intended to label or describe another field (tabular data intent), but the grid does not meet all data-table conditions. + +Both manifestations have the same root cause: tabular semantics were intended but the heuristic ultimately rendered the grid as a layout table. + +## Anti Pattern + +A single field that keeps its visible caption is enough to demote an entire would-be data-table grid into a layout table — and silently strip the labels off its sibling captionless fields. Either restructure to meet all three conditions, or restore captions on every editable field. + +See sample: `tabular-intent-requires-data-table-conditions.bad.al`. diff --git a/microsoft/knowledge/upgrade/breaking-changes-only-on-tables-without-data.bad.al b/microsoft/knowledge/upgrade/breaking-changes-only-on-tables-without-data.bad.al new file mode 100644 index 0000000..00ed5e3 --- /dev/null +++ b/microsoft/knowledge/upgrade/breaking-changes-only-on-tables-without-data.bad.al @@ -0,0 +1,15 @@ +// A pre-existing table with millions of rows. Changing the primary key or +// widening a field type without an upgrade plan can fail at deployment. +tableextension 50233 "Cust Ledger Entry Ext" extends "Cust. Ledger Entry" +{ + fields + { + // Widening Integer to BigInteger on an existing column with persisted data + // requires an upgrade plan and value-range evidence; not safe as a bare edit. + modify("Entry No.") + { + // (hypothetical: field type change goes here) + } + } + // No accompanying upgrade codeunit, no upgrade tag, no overflow verification. +} diff --git a/microsoft/knowledge/upgrade/breaking-changes-only-on-tables-without-data.good.al b/microsoft/knowledge/upgrade/breaking-changes-only-on-tables-without-data.good.al new file mode 100644 index 0000000..455477a --- /dev/null +++ b/microsoft/knowledge/upgrade/breaking-changes-only-on-tables-without-data.good.al @@ -0,0 +1,16 @@ +// New feature table introduced in the same change as the keys / field types. +// No existing data, so the layout is free to choose. +table 50232 "New Feature Table" +{ + fields + { + field(1; "Entry No."; BigInteger) { } + field(2; "Customer No."; Code[20]) { } + field(3; "Posting Date"; Date) { } + } + keys + { + key(PK; "Entry No.") { Clustered = true; } + key(ByCustomer; "Customer No.", "Posting Date") { } + } +} diff --git a/microsoft/knowledge/upgrade/breaking-changes-only-on-tables-without-data.md b/microsoft/knowledge/upgrade/breaking-changes-only-on-tables-without-data.md new file mode 100644 index 0000000..9ba7e8a --- /dev/null +++ b/microsoft/knowledge/upgrade/breaking-changes-only-on-tables-without-data.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: upgrade +keywords: [primary-key, field-type, breaking-change, integer-to-biginteger, existing-data] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Primary-key and field-type changes are safe only on tables without existing data + +## Description + +Primary-key changes and field-type changes (for example widening `Integer` to `BigInteger`) rewrite the on-disk layout of every row in the table. On a new feature table that ships in the same change as the modification, no rows exist and the change is free. On an existing table that already holds tenant data — base-app tables, ledger entries, anything that has been live across releases — the same change can fail outright (key uniqueness violations, value overflow on conversion) or require a full table rewrite during the upgrade window. Either way, the change needs an explicit migration design, not just a metadata edit. + +## Best Practice + +Treat primary-key and field-type changes as restricted to tables introduced in the same change. For changes on tables with existing data, design and ship the corresponding upgrade procedure (typically backed by `DataTransfer` and an upgrade tag) that guarantees the new layout is achievable for every row, and verify with concrete evidence that the existing values fit the new constraint (no PK collisions, no value-range overflow). + +See sample: `breaking-changes-only-on-tables-without-data.good.al`. + +## Anti Pattern + +Changing the primary key on a base-app table, or widening / narrowing a field type on a table that has been shipping for releases, with no accompanying upgrade plan. The change compiles cleanly and may even deploy on an empty-ish tenant, then fails on customers who actually have data. + +See sample: `breaking-changes-only-on-tables-without-data.bad.al`. diff --git a/microsoft/knowledge/upgrade/datatransfer-for-bulk-init.bad.al b/microsoft/knowledge/upgrade/datatransfer-for-bulk-init.bad.al new file mode 100644 index 0000000..30c601a --- /dev/null +++ b/microsoft/knowledge/upgrade/datatransfer-for-bulk-init.bad.al @@ -0,0 +1,23 @@ +codeunit 50219 "Upgrade Price List Source" +{ + Subtype = Upgrade; + + local procedure UpdatePriceSourceGroupInPriceListLines() + var + PriceListLine: Record "Price List Line"; + begin + // One round-trip per row across a potentially large table. + PriceListLine.SetRange("Source Group", "Price Source Group"::All); + if PriceListLine.FindSet(true) then + repeat + if PriceListLine."Source Type" in + ["Price Source Type"::"All Jobs", + "Price Source Type"::Job, + "Price Source Type"::"Job Task"] + then begin + PriceListLine."Source Group" := "Price Source Group"::Job; + PriceListLine.Modify(); + end; + until PriceListLine.Next() = 0; + end; +} diff --git a/microsoft/knowledge/upgrade/datatransfer-for-bulk-init.good.al b/microsoft/knowledge/upgrade/datatransfer-for-bulk-init.good.al new file mode 100644 index 0000000..119e9f0 --- /dev/null +++ b/microsoft/knowledge/upgrade/datatransfer-for-bulk-init.good.al @@ -0,0 +1,23 @@ +codeunit 50218 "Upgrade Price List Source" +{ + Subtype = Upgrade; + + local procedure UpdatePriceSourceGroupInPriceListLines() + var + PriceListLine: Record "Price List Line"; + PriceListLineDataTransfer: DataTransfer; + begin + PriceListLineDataTransfer.SetTables(Database::"Price List Line", Database::"Price List Line"); + PriceListLineDataTransfer.AddSourceFilter( + PriceListLine.FieldNo("Source Group"), '=%1', "Price Source Group"::All); + PriceListLineDataTransfer.AddSourceFilter( + PriceListLine.FieldNo("Source Type"), '%1|%2|%3', + "Price Source Type"::"All Jobs", + "Price Source Type"::Job, + "Price Source Type"::"Job Task"); + PriceListLineDataTransfer.AddConstantValue( + "Price Source Group"::Job, PriceListLine.FieldNo("Source Group")); + PriceListLineDataTransfer.CopyFields(); + Clear(PriceListLineDataTransfer); + end; +} diff --git a/microsoft/knowledge/upgrade/datatransfer-for-bulk-init.md b/microsoft/knowledge/upgrade/datatransfer-for-bulk-init.md new file mode 100644 index 0000000..3eeaa46 --- /dev/null +++ b/microsoft/knowledge/upgrade/datatransfer-for-bulk-init.md @@ -0,0 +1,30 @@ +--- +bc-version: [all] +domain: upgrade +keywords: [datatransfer, large-dataset, bulk-update, modifyall, copyfields, new-field] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Use `DataTransfer` for bulk updates on large tables + +## Description + +Tables that can contain more than 300,000 records, and any newly added field on an existing table, should be initialized with `DataTransfer` rather than a `repeat ... Modify ... until Next() = 0` loop. `DataTransfer` issues a single set-based statement to the database; the loop/modify pattern issues one round-trip per row and accumulates write locks for the duration of the upgrade. On the volumes that drive upgrade pain — ledger entries, item ledger entries, price list lines — the difference is the upgrade running for minutes instead of hours. + +## Best Practice + +For a bulk update use a `DataTransfer` variable: call `SetTables(Database::"...", Database::"...")` (source and destination may be the same table), add filters with `AddSourceFilter`, set the target value with `AddConstantValue` (or copy a source field with `AddFieldValue`), and execute with `CopyFields()`. To express multiple distinct updates against the same table, `Clear` the `DataTransfer` between executions and configure the next one. + +See sample: `datatransfer-for-bulk-init.good.al`. + +## Anti Pattern + +Iterating with `FindSet(true) ... repeat ... Modify() ... until Next() = 0` to set a single field across an entire large table. On 300k+ rows this is the canonical slow-upgrade footgun. + +See sample: `datatransfer-for-bulk-init.bad.al`. + +## See also + +- `datatransfer-skips-triggers-and-subscribers.md` — `DataTransfer` does not raise field validation triggers or event subscribers; if a row needs validation logic, `DataTransfer` is the wrong tool. diff --git a/microsoft/knowledge/upgrade/datatransfer-skips-triggers-and-subscribers.bad.al b/microsoft/knowledge/upgrade/datatransfer-skips-triggers-and-subscribers.bad.al new file mode 100644 index 0000000..800c828 --- /dev/null +++ b/microsoft/knowledge/upgrade/datatransfer-skips-triggers-and-subscribers.bad.al @@ -0,0 +1,16 @@ +codeunit 50221 "Upgrade Existing Field" +{ + Subtype = Upgrade; + + local procedure UpdateCustomerCreditLimit() + var + Customer: Record Customer; + DT: DataTransfer; + begin + // "Credit Limit (LCY)" has OnValidate logic that recalculates risk fields + // and notifies subscribers. DataTransfer skips both — derived data drifts. + DT.SetTables(Database::Customer, Database::Customer); + DT.AddConstantValue(50000, Customer.FieldNo("Credit Limit (LCY)")); + DT.CopyFields(); + end; +} diff --git a/microsoft/knowledge/upgrade/datatransfer-skips-triggers-and-subscribers.good.al b/microsoft/knowledge/upgrade/datatransfer-skips-triggers-and-subscribers.good.al new file mode 100644 index 0000000..0475079 --- /dev/null +++ b/microsoft/knowledge/upgrade/datatransfer-skips-triggers-and-subscribers.good.al @@ -0,0 +1,16 @@ +codeunit 50220 "Upgrade New Field Init" +{ + Subtype = Upgrade; + + local procedure InitializeNewFlagOnMyTable() + var + MyTable: Record "My Table"; + DT: DataTransfer; + begin + // "New Flag" is added in the same change as this upgrade procedure. + // No existing validation logic depends on it, so DataTransfer is safe. + DT.SetTables(Database::"My Table", Database::"My Table"); + DT.AddConstantValue(true, MyTable.FieldNo("New Flag")); + DT.CopyFields(); + end; +} diff --git a/microsoft/knowledge/upgrade/datatransfer-skips-triggers-and-subscribers.md b/microsoft/knowledge/upgrade/datatransfer-skips-triggers-and-subscribers.md new file mode 100644 index 0000000..785684f --- /dev/null +++ b/microsoft/knowledge/upgrade/datatransfer-skips-triggers-and-subscribers.md @@ -0,0 +1,28 @@ +--- +bc-version: [all] +domain: upgrade +keywords: [datatransfer, validate-trigger, event-subscriber, side-effects, business-logic] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# `DataTransfer` does not fire validation triggers or event subscribers + +## Description + +`DataTransfer` writes directly at the database layer. It does not invoke field `OnValidate` triggers, table `OnModify` triggers, or any `OnAfterModifyEvent` / `OnBeforeValidate...` event subscribers that a normal `Record.Modify(true)` would. This is precisely what makes it fast — and precisely what makes it a footgun when the field being updated has validation logic that other code relies on. The receiving code never gets the signal that a row changed, derived fields stay stale, audit hooks do not run. + +For *new fields and tables added in the same change* this is fine: nothing yet depends on the validation. For *pre-existing fields with validation logic*, `DataTransfer` quietly bypasses business logic that may be load-bearing for posting, calculation, or integration scenarios. + +## Best Practice + +Use `DataTransfer` only when the field or table is new in the same change — initial population is the canonical safe case. When updating a pre-existing field that has validation logic, either use `Modify(true)` to honour the triggers, or, if `DataTransfer` is still required for performance reasons, leave a comment that explicitly states "validation triggers and event subscribers are intentionally not raised" and verify with the field's owner that this is safe. + +See sample: `datatransfer-skips-triggers-and-subscribers.good.al`. + +## Anti Pattern + +Reaching for `DataTransfer` to update an existing field with non-trivial `OnValidate` logic, without a comment and without confirming that subscribers can be skipped. The upgrade succeeds; runtime behaviour drifts silently. + +See sample: `datatransfer-skips-triggers-and-subscribers.bad.al`. diff --git a/microsoft/knowledge/upgrade/do-not-block-upgrade-on-data-errors.bad.al b/microsoft/knowledge/upgrade/do-not-block-upgrade-on-data-errors.bad.al new file mode 100644 index 0000000..2c200c6 --- /dev/null +++ b/microsoft/knowledge/upgrade/do-not-block-upgrade-on-data-errors.bad.al @@ -0,0 +1,17 @@ +codeunit 50207 "Upgrade Graceful" +{ + Subtype = Upgrade; + + trigger OnUpgradePerCompany() + begin + UpgradeCustomerLink('C00010'); + end; + + local procedure UpgradeCustomerLink(CustomerNo: Code[20]) + var + Customer: Record Customer; + begin + // Throws if the record is missing — aborts the upgrade. + Customer.Get(CustomerNo); + end; +} diff --git a/microsoft/knowledge/upgrade/do-not-block-upgrade-on-data-errors.good.al b/microsoft/knowledge/upgrade/do-not-block-upgrade-on-data-errors.good.al new file mode 100644 index 0000000..7a83df2 --- /dev/null +++ b/microsoft/knowledge/upgrade/do-not-block-upgrade-on-data-errors.good.al @@ -0,0 +1,26 @@ +codeunit 50206 "Upgrade Graceful" +{ + Subtype = Upgrade; + + trigger OnUpgradePerCompany() + begin + UpgradeCustomerLink('C00010'); + end; + + local procedure UpgradeCustomerLink(CustomerNo: Code[20]) + var + Customer: Record Customer; + begin + if not Customer.Get(CustomerNo) then begin + Session.LogMessage( + '0000ABC', + 'Customer not found during upgrade', + Verbosity::Warning, + DataClassification::SystemMetadata, + TelemetryScope::ExtensionPublisher, + 'CustomerNo', CustomerNo); + exit; + end; + // Continue upgrade work using Customer ... + end; +} diff --git a/microsoft/knowledge/upgrade/do-not-block-upgrade-on-data-errors.md b/microsoft/knowledge/upgrade/do-not-block-upgrade-on-data-errors.md new file mode 100644 index 0000000..cf585eb --- /dev/null +++ b/microsoft/knowledge/upgrade/do-not-block-upgrade-on-data-errors.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: upgrade +keywords: [error-handling, telemetry, session-logmessage, blocking, graceful] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Log telemetry; do not raise errors that block the upgrade + +## Description + +When upgrade code encounters unexpected data — a record it expected to find, a relationship it assumed to be intact — the response is to log telemetry and continue, not to raise an error. A runtime error inside an upgrade codeunit aborts the upgrade for the company or database, leaving the customer stuck on the old version. Customers should not be blocked from upgrading because of a data inconsistency that an upgrade routine could not have anticipated. + +## Best Practice + +When an upgrade procedure detects something missing, call `Session.LogMessage` with a stable event ID, classify the message verbosity (typically `Warning`), and `exit` the procedure so the rest of the upgrade can proceed. The platform telemetry then surfaces the situation to the partner without breaking the customer. + +See sample: `do-not-block-upgrade-on-data-errors.good.al`. + +## Anti Pattern + +Calling `Record.Get(Key)` (or any other erroring API) and letting the error propagate out of the upgrade trigger. The first tenant with imperfect data fails to upgrade, and the failure surfaces as a hard upgrade error rather than as a telemetry signal. + +See sample: `do-not-block-upgrade-on-data-errors.bad.al`. diff --git a/microsoft/knowledge/upgrade/enum-values-additive-at-end.bad.al b/microsoft/knowledge/upgrade/enum-values-additive-at-end.bad.al new file mode 100644 index 0000000..ad16066 --- /dev/null +++ b/microsoft/knowledge/upgrade/enum-values-additive-at-end.bad.al @@ -0,0 +1,11 @@ +enum 50226 "My Enum" +{ + Extensible = true; + + value(0; "First") { } + value(1; "NewMiddleValue") { } // Inserted in the middle — shifts ordinals. + value(2; "Second") { } + value(3; "Third") { } + // Or: a previously declared value(1; "Second") removed without obsoletion — + // any persisted "1" now maps to whatever currently occupies ordinal 1. +} diff --git a/microsoft/knowledge/upgrade/enum-values-additive-at-end.good.al b/microsoft/knowledge/upgrade/enum-values-additive-at-end.good.al new file mode 100644 index 0000000..a585a2c --- /dev/null +++ b/microsoft/knowledge/upgrade/enum-values-additive-at-end.good.al @@ -0,0 +1,9 @@ +enum 50225 "My Enum" +{ + Extensible = true; + + value(0; "First") { } + value(1; "Second") { } + value(2; "Third") { } + value(3; "NewValue") { } // Appended at the end — no existing ordinal shifts. +} diff --git a/microsoft/knowledge/upgrade/enum-values-additive-at-end.md b/microsoft/knowledge/upgrade/enum-values-additive-at-end.md new file mode 100644 index 0000000..4de929b --- /dev/null +++ b/microsoft/knowledge/upgrade/enum-values-additive-at-end.md @@ -0,0 +1,31 @@ +--- +bc-version: [all] +domain: upgrade +keywords: [enum, ordinal, additive, append, backward-compatible, breaking-change] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Add new enum values only at the end + +## Description + +An AL `enum` is a fixed list of ordinal-named values. Persisted rows reference enum members by ordinal, not by name. The only enum mutation that preserves the meaning of every existing row is **appending a new value at the end** — every previously valid ordinal still maps to the same member. Inserting a new value in the middle, renumbering existing values, or removing a value without obsoletion all shift ordinals: rows written with the old layout silently take on the new member at their saved ordinal. + +## Best Practice + +When adding an enum value, place it after the last existing `value(N; ...)` entry, with an ordinal strictly greater than every existing one. Never renumber existing entries. To retire a value, do not delete it: mark it `ObsoleteState = Pending` (and later `Removed`) with `ObsoleteReason` and `ObsoleteTag` so the ordinal remains taken. + +See sample: `enum-values-additive-at-end.good.al`. + +## Anti Pattern + +Inserting a value between existing entries ("just put `NewMiddleValue` between `First` and `Second`"), or removing a value from the enum without first going through `ObsoleteState = Pending` → `Removed`. Every row whose persisted ordinal matched the removed or shifted value now reads as a different member. + +See sample: `enum-values-additive-at-end.bad.al`. + +## See also + +- `obsoletion-requires-reason-and-tag.md` — how to retire an enum member correctly. +- `obsolete-pending-to-removed-staging.md` — the `Pending → Removed` lifecycle. diff --git a/microsoft/knowledge/upgrade/first-install-dataversion-zero-check.bad.al b/microsoft/knowledge/upgrade/first-install-dataversion-zero-check.bad.al new file mode 100644 index 0000000..e3381ad --- /dev/null +++ b/microsoft/knowledge/upgrade/first-install-dataversion-zero-check.bad.al @@ -0,0 +1,13 @@ +codeunit 50211 "Install My Extension" +{ + Subtype = Install; + + trigger OnInstallAppPerCompany() + begin + // No DataVersion() guard — this runs on every reinstall and upgrade + // path, duplicating seed rows. + SeedDefaultRows(); + end; + + local procedure SeedDefaultRows() begin end; +} diff --git a/microsoft/knowledge/upgrade/first-install-dataversion-zero-check.good.al b/microsoft/knowledge/upgrade/first-install-dataversion-zero-check.good.al new file mode 100644 index 0000000..9d6e92e --- /dev/null +++ b/microsoft/knowledge/upgrade/first-install-dataversion-zero-check.good.al @@ -0,0 +1,15 @@ +codeunit 50210 "Install My Extension" +{ + Subtype = Install; + + trigger OnInstallAppPerCompany() + var + AppInfo: ModuleInfo; + begin + NavApp.GetCurrentModuleInfo(AppInfo); + if AppInfo.DataVersion() <> Version.Create('0.0.0.0') then + exit; + + // Install-only seed code goes here. + end; +} diff --git a/microsoft/knowledge/upgrade/first-install-dataversion-zero-check.md b/microsoft/knowledge/upgrade/first-install-dataversion-zero-check.md new file mode 100644 index 0000000..260b15b --- /dev/null +++ b/microsoft/knowledge/upgrade/first-install-dataversion-zero-check.md @@ -0,0 +1,30 @@ +--- +bc-version: [all] +domain: upgrade +keywords: [dataversion, first-install, on-install-app-per-company, moduleinfo, zero-version] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Detect first install with `DataVersion() = Version.Create('0.0.0.0')` + +## Description + +On the first install of an extension on a tenant the platform records a zero data version: `AppInfo.DataVersion()` returns `Version.Create('0.0.0.0')`. Subsequent upgrades record the actual previous version. The `OnInstallAppPerCompany` trigger uses this distinction to detect a brand-new install — for example, to seed default rows that should not be re-inserted on a normal upgrade. This is the one place where reading `DataVersion()` is the right tool; for everything else, use an upgrade tag. + +## Best Practice + +In `OnInstallAppPerCompany`, fetch the current `ModuleInfo` via `NavApp.GetCurrentModuleInfo`, compare `AppInfo.DataVersion()` to `Version.Create('0.0.0.0')`, and run install-only seed logic only when they match. On any non-zero data version, exit immediately — that path is an upgrade, not an install. + +See sample: `first-install-dataversion-zero-check.good.al`. + +## Anti Pattern + +Treating `OnInstallAppPerCompany` as if it always implies "fresh tenant". The trigger also fires when reinstalling over an existing data set; without the `0.0.0.0` guard, install-only seed code re-runs on every upgrade and duplicates rows. + +See sample: `first-install-dataversion-zero-check.bad.al`. + +## See also + +- `use-upgrade-tags-not-version-checks.md` — for upgrade steps after first install, use upgrade tags rather than `DataVersion`. diff --git a/microsoft/knowledge/upgrade/guard-database-reads.bad.al b/microsoft/knowledge/upgrade/guard-database-reads.bad.al new file mode 100644 index 0000000..0c28fa6 --- /dev/null +++ b/microsoft/knowledge/upgrade/guard-database-reads.bad.al @@ -0,0 +1,20 @@ +codeunit 50205 "Upgrade Guarded Reads" +{ + Subtype = Upgrade; + + trigger OnUpgradePerCompany() + begin + UpgradeMyFeature(); + end; + + local procedure UpgradeMyFeature() + var + Item: Record Item; + Customer: Record Customer; + Vendor: Record Vendor; + begin + Item.Get('1000'); // Throws if missing; aborts upgrade. + Customer.FindSet(); // Throws if empty. + Vendor.FindLast(); // Throws if empty. + end; +} diff --git a/microsoft/knowledge/upgrade/guard-database-reads.good.al b/microsoft/knowledge/upgrade/guard-database-reads.good.al new file mode 100644 index 0000000..868f2ae --- /dev/null +++ b/microsoft/knowledge/upgrade/guard-database-reads.good.al @@ -0,0 +1,22 @@ +codeunit 50204 "Upgrade Guarded Reads" +{ + Subtype = Upgrade; + + trigger OnUpgradePerCompany() + begin + UpgradeMyFeature(); + end; + + local procedure UpgradeMyFeature() + var + Item: Record Item; + Customer: Record Customer; + Vendor: Record Vendor; + begin + if Item.Get('1000') then + Item.Modify(); + if Customer.FindSet() then; + if not Vendor.FindLast() then + exit; + end; +} diff --git a/microsoft/knowledge/upgrade/guard-database-reads.md b/microsoft/knowledge/upgrade/guard-database-reads.md new file mode 100644 index 0000000..c5bc206 --- /dev/null +++ b/microsoft/knowledge/upgrade/guard-database-reads.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: upgrade +keywords: [get, findset, findlast, guard, if-then, runtime-error] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Guard every database read in upgrade code with `if` + +## Description + +Inside an upgrade codeunit (or any procedure transitively invoked from `OnUpgradePerCompany` / `OnUpgradePerDatabase`), an unguarded `Record.Get`, `Record.FindSet`, or `Record.FindLast` raises a runtime error when the row or set is missing. In upgrade context that error aborts the entire upgrade for the company or database — a far worse outcome than the missing data itself. Records the upgrade reasons about may legitimately not exist on every customer's tenant. + +## Best Practice + +Wrap every read in an `if`. `if Item.Get(No) then ...`, `if Customer.FindSet() then;`, `if not Vendor.FindLast() then exit;`. The empty-then form `if Customer.FindSet() then;` is the idiomatic way to attempt a read whose only purpose is to position a record, while swallowing the "not found" case. + +See sample: `guard-database-reads.good.al`. + +## Anti Pattern + +Calling `Item.Get()`, `Customer.FindSet()`, or `Vendor.FindLast()` bare in upgrade code. The first tenant whose data does not match the upgrade's assumptions will fail to upgrade. + +See sample: `guard-database-reads.bad.al`. diff --git a/microsoft/knowledge/upgrade/hybrid-migration-codeunits-not-standard-upgrade.md b/microsoft/knowledge/upgrade/hybrid-migration-codeunits-not-standard-upgrade.md new file mode 100644 index 0000000..2d047ac --- /dev/null +++ b/microsoft/knowledge/upgrade/hybrid-migration-codeunits-not-standard-upgrade.md @@ -0,0 +1,24 @@ +--- +bc-version: [all] +domain: upgrade +keywords: [hybrid-migration, hybrid-bc14, hybrid-sl, hybrid-gp, hybrid-base-deployment, one-time-migration] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Hybrid migration codeunits are not standard upgrade codeunits + +## Description + +Codeunits like `HybridBC14`, `HybridSL`, `HybridGP`, and `HybridBaseDeployment` implement one-time migration paths from a specific source system into Business Central. They run in a different pipeline from the standard per-company / per-database upgrade triggers and follow patterns shaped by that source — staging tables, schema-mapped imports, and per-source post-processing. The rules that apply to standard upgrade codeunits — guarded reads, no external calls, `DataTransfer` for bulk init, `Subtype = Upgrade`, upgrade tags — are not the right yardstick for these migration codeunits. + +## Best Practice + +Treat a hybrid migration codeunit as a domain of its own. If you need to add or modify migration logic, follow the conventions of the surrounding hybrid migration codebase (which has its own dispatcher, its own way of recording progress, and its own error handling) rather than imposing standard upgrade conventions on it. Conversely, do not borrow hybrid-migration patterns into standard upgrade codeunits — the platform contract is different. + +When reviewing changes inside a hybrid migration codeunit, do not flag missing upgrade tags, missing `Subtype = Upgrade`, or missing `OnUpgradePerCompany` wiring. None of those apply. + +## Anti Pattern + +Reviewing a change inside `HybridBC14` / `HybridSL` / `HybridGP` / `HybridBaseDeployment` against standard upgrade rules and flagging the absence of `Subtype = Upgrade` or upgrade-tag plumbing. diff --git a/microsoft/knowledge/upgrade/initvalue-does-not-update-existing-rows.bad.al b/microsoft/knowledge/upgrade/initvalue-does-not-update-existing-rows.bad.al new file mode 100644 index 0000000..3ca0ab7 --- /dev/null +++ b/microsoft/knowledge/upgrade/initvalue-does-not-update-existing-rows.bad.al @@ -0,0 +1,15 @@ +tableextension 50224 "MyTable Ext" extends "My Table" +{ + fields + { + // InitValue only applies to rows inserted after deployment. + // Pre-existing rows silently carry the datatype default (false). + field(50200; "New Flag"; Boolean) + { + DataClassification = CustomerContent; + Caption = 'New Flag'; + InitValue = true; + } + } + // No accompanying upgrade codeunit to back-fill existing rows. +} diff --git a/microsoft/knowledge/upgrade/initvalue-does-not-update-existing-rows.good.al b/microsoft/knowledge/upgrade/initvalue-does-not-update-existing-rows.good.al new file mode 100644 index 0000000..c61284b --- /dev/null +++ b/microsoft/knowledge/upgrade/initvalue-does-not-update-existing-rows.good.al @@ -0,0 +1,43 @@ +tableextension 50222 "MyTable Ext" extends "My Table" +{ + fields + { + field(50200; "New Flag"; Boolean) + { + DataClassification = CustomerContent; + Caption = 'New Flag'; + InitValue = true; + } + } +} + +codeunit 50223 "Upgrade MyTable NewFlag" +{ + Subtype = Upgrade; + + trigger OnUpgradePerCompany() + begin + UpgradeMyTableNewFlag(); + end; + + local procedure UpgradeMyTableNewFlag() + var + MyTable: Record "My Table"; + UpgradeTag: Codeunit "Upgrade Tag"; + DT: DataTransfer; + begin + if UpgradeTag.HasUpgradeTag(MyTableNewFlagTag()) then + exit; + + DT.SetTables(Database::"My Table", Database::"My Table"); + DT.AddConstantValue(true, MyTable.FieldNo("New Flag")); + DT.CopyFields(); + + UpgradeTag.SetUpgradeTag(MyTableNewFlagTag()); + end; + + local procedure MyTableNewFlagTag(): Code[250] + begin + exit('MS-123456-MyTable-NewFlag-20240101'); + end; +} diff --git a/microsoft/knowledge/upgrade/initvalue-does-not-update-existing-rows.md b/microsoft/knowledge/upgrade/initvalue-does-not-update-existing-rows.md new file mode 100644 index 0000000..4733ef2 --- /dev/null +++ b/microsoft/knowledge/upgrade/initvalue-does-not-update-existing-rows.md @@ -0,0 +1,32 @@ +--- +bc-version: [all] +domain: upgrade +keywords: [initvalue, new-field, existing-rows, default-value, table-extension] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# `InitValue` does not back-fill existing rows + +## Description + +`InitValue` on a field defines the value the platform assigns when a *new* record is inserted. It does not touch rows that already exist when the field is added. When a new field is added to an existing table — directly or via a table extension — every pre-existing row receives the datatype default (`false` for Boolean, `0` for numeric, empty for text), not the `InitValue`. If the intended semantics require existing rows to carry the `InitValue`, the change is incomplete without an upgrade routine that sets the field on those rows. + +Several legitimate cases do NOT need upgrade code: +- New fields on brand-new tables (no existing rows). +- New `Boolean` fields without `InitValue` where the datatype default `false` is the intended value. +- New fields on configuration / setup tables that have no meaningful "existing data". +- Informational or optional fields (logging, preferences, tracking) where `false` / empty is a valid state. + +## Best Practice + +When a new field on an existing table has an `InitValue` that matters, ship an upgrade procedure that walks the existing rows and sets the field to the same value — typically via `DataTransfer.AddConstantValue` for performance — guarded by an upgrade tag. + +See sample: `initvalue-does-not-update-existing-rows.good.al`. + +## Anti Pattern + +Adding a field with `InitValue = true;` (or any non-default `InitValue`) and shipping no upgrade code. Existing rows silently carry the datatype default, leaving the table in two states: rows created before the upgrade with the wrong value, and rows created after with the right one. + +See sample: `initvalue-does-not-update-existing-rows.bad.al`. diff --git a/microsoft/knowledge/upgrade/minimize-onvalidate-upgrade-triggers.bad.al b/microsoft/knowledge/upgrade/minimize-onvalidate-upgrade-triggers.bad.al new file mode 100644 index 0000000..69994e6 --- /dev/null +++ b/microsoft/knowledge/upgrade/minimize-onvalidate-upgrade-triggers.bad.al @@ -0,0 +1,13 @@ +codeunit 50235 "Upgrade With Validation" +{ + Subtype = Upgrade; + + trigger OnValidateUpgradePerCompany() + begin + // No skip logic and no written justification — full-table validation + // runs on every single upgrade pass. + ValidateAllCustomers(); + end; + + local procedure ValidateAllCustomers() begin end; +} diff --git a/microsoft/knowledge/upgrade/minimize-onvalidate-upgrade-triggers.good.al b/microsoft/knowledge/upgrade/minimize-onvalidate-upgrade-triggers.good.al new file mode 100644 index 0000000..9a5a83b --- /dev/null +++ b/microsoft/knowledge/upgrade/minimize-onvalidate-upgrade-triggers.good.al @@ -0,0 +1,25 @@ +codeunit 50234 "Upgrade With Validation" +{ + Subtype = Upgrade; + + trigger OnValidateUpgradePerCompany() + var + UpgradeTag: Codeunit "Upgrade Tag"; + begin + // Justification: regulatory compliance requires a full-table scan once + // per tenant after this release. Tag prevents re-runs. + if UpgradeTag.HasUpgradeTag(MyValidationUpgradeTag()) then + exit; + + ValidateAllCustomers(); + + UpgradeTag.SetUpgradeTag(MyValidationUpgradeTag()); + end; + + local procedure ValidateAllCustomers() begin end; + + local procedure MyValidationUpgradeTag(): Code[250] + begin + exit('MS-123456-CustomerValidation-20240101'); + end; +} diff --git a/microsoft/knowledge/upgrade/minimize-onvalidate-upgrade-triggers.md b/microsoft/knowledge/upgrade/minimize-onvalidate-upgrade-triggers.md new file mode 100644 index 0000000..2c02def --- /dev/null +++ b/microsoft/knowledge/upgrade/minimize-onvalidate-upgrade-triggers.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: upgrade +keywords: [on-validate-upgrade-per-company, performance-impact, skip-logic, justification, upgrade-tag] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Performance-impacting upgrade triggers need justification and skip logic + +## Description + +Triggers such as `OnValidateUpgradePerCompany` run on every upgrade pass. When their body performs non-trivial work — full-table scans, cross-table validations — the cost is paid on every upgrade of every tenant, even when there is nothing to validate. That cost is acceptable only when the validation is critical (regulatory compliance, data-integrity guarantees the platform depends on) AND the trigger short-circuits once it has done its work. + +## Best Practice + +A performance-impacting upgrade trigger carries two things: a written comment that names the reason the work has to happen on every upgrade pass, and an early-exit guard backed by an upgrade tag so the work runs at most once per tenant. The `HasUpgradeTag` check at the top exits when the validation has already been recorded; the `SetUpgradeTag` call at the bottom records completion. + +See sample: `minimize-onvalidate-upgrade-triggers.good.al`. + +## Anti Pattern + +Doing real work in `OnValidateUpgradePerCompany` with no upgrade-tag guard. The same scan runs every upgrade, multiplying upgrade time by the number of releases the customer takes. + +See sample: `minimize-onvalidate-upgrade-triggers.bad.al`. diff --git a/microsoft/knowledge/upgrade/no-external-calls-in-upgrade.bad.al b/microsoft/knowledge/upgrade/no-external-calls-in-upgrade.bad.al new file mode 100644 index 0000000..2827b96 --- /dev/null +++ b/microsoft/knowledge/upgrade/no-external-calls-in-upgrade.bad.al @@ -0,0 +1,13 @@ +codeunit 50215 "Upgrade No External" +{ + Subtype = Upgrade; + + trigger OnUpgradePerCompany() + var + Client: HttpClient; + Response: HttpResponseMessage; + begin + // External call inside upgrade code — can hang or fail and abort the upgrade. + Client.Get('https://external-service.contoso.com/api/sync', Response); + end; +} diff --git a/microsoft/knowledge/upgrade/no-external-calls-in-upgrade.good.al b/microsoft/knowledge/upgrade/no-external-calls-in-upgrade.good.al new file mode 100644 index 0000000..48f62b1 --- /dev/null +++ b/microsoft/knowledge/upgrade/no-external-calls-in-upgrade.good.al @@ -0,0 +1,17 @@ +codeunit 50214 "Upgrade No External" +{ + Subtype = Upgrade; + + trigger OnUpgradePerCompany() + var + ExternalSyncSetup: Record "External Sync Setup"; + begin + // Defer the external call: just set a flag the runtime path will pick up. + if not ExternalSyncSetup.Get() then begin + ExternalSyncSetup.Init(); + ExternalSyncSetup.Insert(); + end; + ExternalSyncSetup."Resync Required" := true; + ExternalSyncSetup.Modify(); + end; +} diff --git a/microsoft/knowledge/upgrade/no-external-calls-in-upgrade.md b/microsoft/knowledge/upgrade/no-external-calls-in-upgrade.md new file mode 100644 index 0000000..eb644b6 --- /dev/null +++ b/microsoft/knowledge/upgrade/no-external-calls-in-upgrade.md @@ -0,0 +1,28 @@ +--- +bc-version: [all] +domain: upgrade +keywords: [httpclient, dotnet, external-service, network-call, blocking, upgrade-rollback] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# No external calls inside upgrade codeunits + +## Description + +Upgrade code runs in a constrained execution window: the tenant is mid-upgrade, no users are signed in, and a failure aborts the entire transaction. An external HTTP call, DotNet interop call, or any other I/O to a system outside Business Central can hang or fail for reasons completely unrelated to the upgrade — DNS, expired credentials, a service that is itself being upgraded — and the upgrade fails with it. Rolling back from such a failure is hard because the upgrade pipeline assumes its work is deterministic. + +The rule applies inside any codeunit with `Subtype = Upgrade` and to any procedure transitively invoked from `OnUpgrade...` triggers. The same calls in regular runtime code — pages, table triggers, normal codeunits, background jobs — are fine. + +## Best Practice + +Defer external calls to runtime code. If a piece of upgrade work conceptually needs data from an external service, set a flag or write a queue row during upgrade and have the runtime code make the call later (for example on first user sign-in or via job queue), where retries and degraded modes are tractable. + +See sample: `no-external-calls-in-upgrade.good.al`. + +## Anti Pattern + +Calling `HttpClient.Get`, `HttpClient.Post`, or DotNet interop methods from `OnUpgradePerCompany`, `OnUpgradePerDatabase`, or any procedure they invoke. + +See sample: `no-external-calls-in-upgrade.bad.al`. diff --git a/microsoft/knowledge/upgrade/obsolete-pending-to-removed-staging.bad.al b/microsoft/knowledge/upgrade/obsolete-pending-to-removed-staging.bad.al new file mode 100644 index 0000000..e28ae94 --- /dev/null +++ b/microsoft/knowledge/upgrade/obsolete-pending-to-removed-staging.bad.al @@ -0,0 +1,14 @@ +// Skipping the Pending stage and going straight to Removed leaves callers +// and persisted rows with no migration window. +enum 50231 "My Enum" +{ + Extensible = true; + value(0; "First") { } + value(1; "Second") + { + ObsoleteState = Removed; + ObsoleteReason = 'Replaced by NewValue'; + ObsoleteTag = '22.0'; + } + value(2; "Third") { } +} diff --git a/microsoft/knowledge/upgrade/obsolete-pending-to-removed-staging.good.al b/microsoft/knowledge/upgrade/obsolete-pending-to-removed-staging.good.al new file mode 100644 index 0000000..be94807 --- /dev/null +++ b/microsoft/knowledge/upgrade/obsolete-pending-to-removed-staging.good.al @@ -0,0 +1,29 @@ +// Release N: deprecation announced. +enum 50229 "My Enum N" +{ + Extensible = true; + value(0; "First") { } + value(1; "Second") + { + ObsoleteState = Pending; + ObsoleteReason = 'Replaced by NewValue'; + ObsoleteTag = '22.0'; + } + value(2; "Third") { } + value(3; "NewValue") { } +} + +// Release N+1 (or later): removal staged; upgrade code now migrates persisted rows. +enum 50230 "My Enum NPlus1" +{ + Extensible = true; + value(0; "First") { } + value(1; "Second") + { + ObsoleteState = Removed; + ObsoleteReason = 'Replaced by NewValue'; + ObsoleteTag = '22.0'; + } + value(2; "Third") { } + value(3; "NewValue") { } +} diff --git a/microsoft/knowledge/upgrade/obsolete-pending-to-removed-staging.md b/microsoft/knowledge/upgrade/obsolete-pending-to-removed-staging.md new file mode 100644 index 0000000..cb008ac --- /dev/null +++ b/microsoft/knowledge/upgrade/obsolete-pending-to-removed-staging.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: upgrade +keywords: [obsolete-state, pending, removed, lifecycle, clean-flag, upgrade-code-timing] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Stage obsoletion `Pending → Removed`; write upgrade code on removal + +## Description + +`ObsoleteState` has a deliberate two-step lifecycle. `Pending` keeps the element compilable and present — callers still find it but receive a deprecation warning. `Removed` marks the element as gone from the contract; the body may be empty or wrapped in `#if not CLEAN` so the symbol survives only for binary compatibility. Upgrade code that migrates persisted data away from the obsolete element is normally written when the element moves to `Removed`, not when it goes `Pending`. `ObsoleteState = Pending` without accompanying upgrade code is the expected steady state during the deprecation window; reviewers should not flag that combination as missing migration. + +## Best Practice + +Stage the deprecation across releases. Step 1: mark `Pending` with reason and tag; consumers are warned but data and code keep working. Step 2: in a later release, transition to `Removed` and (if persisted data references the element) ship an upgrade procedure that migrates that data — gated by an upgrade tag. The standard mechanic for retiring the actual implementation body is to remove the `#if not CLEAN` block in the same release that flips the state to `Removed`. + +See sample: `obsolete-pending-to-removed-staging.good.al`. + +## Anti Pattern + +Jumping straight to `ObsoleteState = Removed` without a prior `Pending` release. Consumers have no deprecation window to migrate and any data still referencing the element is stranded. Equally wrong: leaving an element `Pending` indefinitely and never staging its removal — the deprecation never completes. + +See sample: `obsolete-pending-to-removed-staging.bad.al`. diff --git a/microsoft/knowledge/upgrade/obsoletion-requires-reason-and-tag.bad.al b/microsoft/knowledge/upgrade/obsoletion-requires-reason-and-tag.bad.al new file mode 100644 index 0000000..22290a4 --- /dev/null +++ b/microsoft/knowledge/upgrade/obsoletion-requires-reason-and-tag.bad.al @@ -0,0 +1,8 @@ +codeunit 50228 "Old Method Holder" +{ + // ObsoleteState set without ObsoleteReason or ObsoleteTag. + [Obsolete('')] + procedure OldMethod() + begin + end; +} diff --git a/microsoft/knowledge/upgrade/obsoletion-requires-reason-and-tag.good.al b/microsoft/knowledge/upgrade/obsoletion-requires-reason-and-tag.good.al new file mode 100644 index 0000000..8562b0c --- /dev/null +++ b/microsoft/knowledge/upgrade/obsoletion-requires-reason-and-tag.good.al @@ -0,0 +1,12 @@ +codeunit 50227 "Old Method Holder" +{ + [Obsolete('Use NewMethod instead for better performance', '22.0')] + procedure OldMethod() + begin + // Body kept while ObsoleteState = Pending; warns at call sites. + end; + + procedure NewMethod() + begin + end; +} diff --git a/microsoft/knowledge/upgrade/obsoletion-requires-reason-and-tag.md b/microsoft/knowledge/upgrade/obsoletion-requires-reason-and-tag.md new file mode 100644 index 0000000..0f2e11e --- /dev/null +++ b/microsoft/knowledge/upgrade/obsoletion-requires-reason-and-tag.md @@ -0,0 +1,36 @@ +--- +bc-version: [all] +domain: upgrade +keywords: [obsolete-state, obsolete-reason, obsolete-tag, deprecation, metadata] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Mark obsolete elements with `ObsoleteState`, `ObsoleteReason`, and `ObsoleteTag` + +## Description + +When a procedure, field, table, page, or enum value is being retired, AL requires three pieces of metadata to declare the deprecation: + +- `ObsoleteState` — `Pending` while the element still exists but is being phased out, `Removed` once it should no longer be used. +- `ObsoleteReason` — a short human-readable string explaining what to use instead. Tooling and downstream consumers surface this when warning callers. +- `ObsoleteTag` — a stable version-like marker (typically the release version in which the deprecation was introduced, e.g. `'22.0'`). + +Omitting `ObsoleteReason` or `ObsoleteTag` leaves consumers with `ObsoleteState = Pending` but no guidance and no traceability. Declaring `ObsoleteState = Removed` without a reason or tag is the same failure with a stronger blast radius. + +## Best Practice + +Every obsoleted element carries all three properties together. The reason names the replacement explicitly; the tag is the version in which the deprecation was introduced and stays stable for the life of the deprecation. + +See sample: `obsoletion-requires-reason-and-tag.good.al`. + +## Anti Pattern + +Setting only `ObsoleteState = Pending;` (or `Removed`) without `ObsoleteReason` and `ObsoleteTag`. Callers see a warning with no explanation, and the deprecation cannot be tracked by version. + +See sample: `obsoletion-requires-reason-and-tag.bad.al`. + +## See also + +- `obsolete-pending-to-removed-staging.md` — when to advance `Pending` to `Removed` and write upgrade code. diff --git a/microsoft/knowledge/upgrade/register-upgrade-tags-with-subscribers.bad.al b/microsoft/knowledge/upgrade/register-upgrade-tags-with-subscribers.bad.al new file mode 100644 index 0000000..adf5cf5 --- /dev/null +++ b/microsoft/knowledge/upgrade/register-upgrade-tags-with-subscribers.bad.al @@ -0,0 +1,20 @@ +codeunit 50213 "Upgrade Tag Registration" +{ + Subtype = Upgrade; + + trigger OnUpgradePerCompany() + var + UpgradeTag: Codeunit "Upgrade Tag"; + begin + if UpgradeTag.HasUpgradeTag(MyUpgradeTag()) then + exit; + UpgradeTag.SetUpgradeTag(MyUpgradeTag()); + end; + + local procedure MyUpgradeTag(): Code[250] + begin + exit('MS-123456-MyFeature-20240101'); + end; + + // No OnGetPerCompanyUpgradeTags subscriber — the tag is unknown to the platform. +} diff --git a/microsoft/knowledge/upgrade/register-upgrade-tags-with-subscribers.good.al b/microsoft/knowledge/upgrade/register-upgrade-tags-with-subscribers.good.al new file mode 100644 index 0000000..02362c9 --- /dev/null +++ b/microsoft/knowledge/upgrade/register-upgrade-tags-with-subscribers.good.al @@ -0,0 +1,25 @@ +codeunit 50212 "Upgrade Tag Registration" +{ + Subtype = Upgrade; + + trigger OnUpgradePerCompany() + var + UpgradeTag: Codeunit "Upgrade Tag"; + begin + if UpgradeTag.HasUpgradeTag(MyUpgradeTag()) then + exit; + // Upgrade work ... + UpgradeTag.SetUpgradeTag(MyUpgradeTag()); + end; + + local procedure MyUpgradeTag(): Code[250] + begin + exit('MS-123456-MyFeature-20240101'); + end; + + [EventSubscriber(ObjectType::Codeunit, Codeunit::"Upgrade Tag", 'OnGetPerCompanyUpgradeTags', '', false, false)] + local procedure RegisterPerCompanyTags(var PerCompanyUpgradeTags: List of [Code[250]]) + begin + PerCompanyUpgradeTags.Add(MyUpgradeTag()); + end; +} diff --git a/microsoft/knowledge/upgrade/register-upgrade-tags-with-subscribers.md b/microsoft/knowledge/upgrade/register-upgrade-tags-with-subscribers.md new file mode 100644 index 0000000..a413520 --- /dev/null +++ b/microsoft/knowledge/upgrade/register-upgrade-tags-with-subscribers.md @@ -0,0 +1,28 @@ +--- +bc-version: [all] +domain: upgrade +keywords: [upgrade-tag, event-subscriber, on-get-per-company-upgrade-tags, on-get-per-database-upgrade-tags, registration] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Register every upgrade tag with the platform via an event subscriber + +## Description + +The `Upgrade Tag` codeunit only recognizes a tag if the tag was published to the platform through one of two events on that codeunit: `OnGetPerCompanyUpgradeTags` for tags set inside `OnUpgradePerCompany`, and `OnGetPerDatabaseUpgradeTags` for tags set inside `OnUpgradePerDatabase`. A tag that is `Set` and `Has`-checked in code but never added to one of these lists is unknown to the platform — its semantics around skip-on-reinstall, telemetry, and operator queries do not apply. + +The registration scope must match where the tag is set: a tag used from `OnUpgradePerCompany` registers in `OnGetPerCompanyUpgradeTags`; a tag used from `OnUpgradePerDatabase` registers in `OnGetPerDatabaseUpgradeTags`. Crossing the scopes silently breaks the tag. + +## Best Practice + +For every new upgrade tag, add one line to the matching subscriber: `PerCompanyUpgradeTags.Add(MyUpgradeTag());` or `PerDatabaseUpgradeTags.Add(MyUpgradeTag());`. Place the subscribers in the same codeunit (or a dedicated "Upgrade Tag Definitions" codeunit) so the tag string and its registration stay together. + +See sample: `register-upgrade-tags-with-subscribers.good.al`. + +## Anti Pattern + +Calling `UpgradeTag.SetUpgradeTag(MyUpgradeTag())` without ever adding `MyUpgradeTag()` to the corresponding `OnGetPerCompany...` / `OnGetPerDatabase...` subscriber. + +See sample: `register-upgrade-tags-with-subscribers.bad.al`. diff --git a/microsoft/knowledge/upgrade/skip-nonessential-work-via-execution-context.bad.al b/microsoft/knowledge/upgrade/skip-nonessential-work-via-execution-context.bad.al new file mode 100644 index 0000000..db0df6f --- /dev/null +++ b/microsoft/knowledge/upgrade/skip-nonessential-work-via-execution-context.bad.al @@ -0,0 +1,12 @@ +codeunit 50217 "Report Selection Seeder" +{ + procedure AddReportSelectionEntries() + var + ReportSelections: Record "Report Selections"; + begin + // No context check — fires during upgrade and silently inserts rows + // the upgrade pipeline never asked for. + ReportSelections.Init(); + ReportSelections.Insert(); + end; +} diff --git a/microsoft/knowledge/upgrade/skip-nonessential-work-via-execution-context.good.al b/microsoft/knowledge/upgrade/skip-nonessential-work-via-execution-context.good.al new file mode 100644 index 0000000..61dfeda --- /dev/null +++ b/microsoft/knowledge/upgrade/skip-nonessential-work-via-execution-context.good.al @@ -0,0 +1,15 @@ +codeunit 50216 "Report Selection Seeder" +{ + procedure AddReportSelectionEntries() + var + ReportSelections: Record "Report Selections"; + begin + // Do not add report-selection entries during upgrade; the upgrade pipeline + // does not need them and re-running this on every upgrade is wasteful. + if GetExecutionContext() = ExecutionContext::Upgrade then + exit; + + ReportSelections.Init(); + ReportSelections.Insert(); + end; +} diff --git a/microsoft/knowledge/upgrade/skip-nonessential-work-via-execution-context.md b/microsoft/knowledge/upgrade/skip-nonessential-work-via-execution-context.md new file mode 100644 index 0000000..0b441e6 --- /dev/null +++ b/microsoft/knowledge/upgrade/skip-nonessential-work-via-execution-context.md @@ -0,0 +1,28 @@ +--- +bc-version: [all] +domain: upgrade +keywords: [get-execution-context, execution-context-upgrade, skip, report-selection, runtime-trigger] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Skip non-essential runtime work when `GetExecutionContext() = ExecutionContext::Upgrade` + +## Description + +Runtime procedures (table triggers, install routines, helpers called from many places) sometimes fire during the upgrade window because the upgrade itself touches the data they react to. When the work those procedures do is not strictly required for the upgrade to succeed — inserting report-selection entries, seeding optional configuration, sending welcome notifications — they should detect upgrade context with `GetExecutionContext() = ExecutionContext::Upgrade` and exit. This keeps upgrade transactions tight and avoids side effects that the upgrade pipeline did not ask for. + +This is the opposite of a load-bearing concern: code that MUST run during the upgrade does not consult execution context. The check is for *optional* side effects that happen to be wired into runtime code paths. + +## Best Practice + +In a runtime procedure that performs non-essential side effects, guard the side-effect block with `if GetExecutionContext() = ExecutionContext::Upgrade then exit;` and include a brief comment explaining what is being skipped and why. + +See sample: `skip-nonessential-work-via-execution-context.good.al`. + +## Anti Pattern + +Using `GetExecutionContext()` to *enable* upgrade behaviour from outside an upgrade codeunit. Upgrade behaviour belongs in a codeunit with `Subtype = Upgrade`; runtime code should only use the check to *suppress* optional work. + +See sample: `skip-nonessential-work-via-execution-context.bad.al`. diff --git a/microsoft/knowledge/upgrade/triggers-call-helpers-not-implementations.bad.al b/microsoft/knowledge/upgrade/triggers-call-helpers-not-implementations.bad.al new file mode 100644 index 0000000..e409ea8 --- /dev/null +++ b/microsoft/knowledge/upgrade/triggers-call-helpers-not-implementations.bad.al @@ -0,0 +1,12 @@ +codeunit 50203 "Upgrade Orchestrator" +{ + Subtype = Upgrade; + + trigger OnUpgradePerCompany() + var + Customer: Record Customer; + begin + // Direct implementation in the trigger body — wrong. + Customer.ModifyAll("Some Field", true); + end; +} diff --git a/microsoft/knowledge/upgrade/triggers-call-helpers-not-implementations.good.al b/microsoft/knowledge/upgrade/triggers-call-helpers-not-implementations.good.al new file mode 100644 index 0000000..d03fa4a --- /dev/null +++ b/microsoft/knowledge/upgrade/triggers-call-helpers-not-implementations.good.al @@ -0,0 +1,19 @@ +codeunit 50202 "Upgrade Orchestrator" +{ + Subtype = Upgrade; + + trigger OnUpgradePerCompany() + begin + UpgradeMyFeature(); + UpgradeSecondFeature(); + end; + + local procedure UpgradeMyFeature() + var + Customer: Record Customer; + begin + Customer.ModifyAll("Some Field", true); + end; + + local procedure UpgradeSecondFeature() begin end; +} diff --git a/microsoft/knowledge/upgrade/triggers-call-helpers-not-implementations.md b/microsoft/knowledge/upgrade/triggers-call-helpers-not-implementations.md new file mode 100644 index 0000000..dcc21e3 --- /dev/null +++ b/microsoft/knowledge/upgrade/triggers-call-helpers-not-implementations.md @@ -0,0 +1,28 @@ +--- +bc-version: [all] +domain: upgrade +keywords: [on-upgrade-per-company, on-upgrade-per-database, trigger-body, helper-procedure, structure] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# `OnUpgradePerCompany` / `OnUpgradePerDatabase` should call helpers, not inline logic + +## Description + +The `OnUpgradePerCompany` and `OnUpgradePerDatabase` triggers on an upgrade codeunit are dispatch points, not implementation slots. They should contain only calls to named local procedures — one call per feature being upgraded. Putting `ModifyAll`, record loops, or any business logic directly inside the trigger body makes the upgrade impossible to read, impossible to selectively skip via upgrade tags per feature, and impossible to extend without touching the trigger itself. + +Empty `OnUpgradePerCompany` / `OnUpgradePerDatabase` triggers are acceptable — they may be placeholders for future use or artifacts from cleanup. + +## Best Practice + +Each upgrade trigger contains an ordered list of procedure calls, one per feature: `UpgradeFeatureA();` `UpgradeFeatureB();`. Each procedure handles its own upgrade tag, its own data work, and can be added or removed independently. + +See sample: `triggers-call-helpers-not-implementations.good.al`. + +## Anti Pattern + +Implementing record loops, `ModifyAll`, or other data work directly in the trigger body. The trigger then mixes orchestration with implementation, and adding a second feature requires editing the trigger rather than appending one line. + +See sample: `triggers-call-helpers-not-implementations.bad.al`. diff --git a/microsoft/knowledge/upgrade/upgrade-codeunit-subtype.bad.al b/microsoft/knowledge/upgrade/upgrade-codeunit-subtype.bad.al new file mode 100644 index 0000000..024443c --- /dev/null +++ b/microsoft/knowledge/upgrade/upgrade-codeunit-subtype.bad.al @@ -0,0 +1,10 @@ +codeunit 50201 "Upgrade My Feature" +{ + // Missing Subtype = Upgrade; the OnUpgrade trigger is never dispatched. + trigger OnUpgradePerCompany() + begin + UpgradeMyFeature(); + end; + + local procedure UpgradeMyFeature() begin end; +} diff --git a/microsoft/knowledge/upgrade/upgrade-codeunit-subtype.good.al b/microsoft/knowledge/upgrade/upgrade-codeunit-subtype.good.al new file mode 100644 index 0000000..5ef4e88 --- /dev/null +++ b/microsoft/knowledge/upgrade/upgrade-codeunit-subtype.good.al @@ -0,0 +1,17 @@ +codeunit 50200 "Upgrade My Feature" +{ + Subtype = Upgrade; + + trigger OnUpgradePerCompany() + begin + UpgradeMyFeature(); + end; + + trigger OnUpgradePerDatabase() + begin + UpgradeMyGlobalSetup(); + end; + + local procedure UpgradeMyFeature() begin end; + local procedure UpgradeMyGlobalSetup() begin end; +} diff --git a/microsoft/knowledge/upgrade/upgrade-codeunit-subtype.md b/microsoft/knowledge/upgrade/upgrade-codeunit-subtype.md new file mode 100644 index 0000000..2dba21a --- /dev/null +++ b/microsoft/knowledge/upgrade/upgrade-codeunit-subtype.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: upgrade +keywords: [upgrade-codeunit, subtype, on-upgrade-per-company, on-upgrade-per-database, trigger] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Upgrade logic must live in a codeunit with `Subtype = Upgrade` + +## Description + +A codeunit only participates in the upgrade pipeline when it sets `Subtype = Upgrade`. The platform then dispatches the `OnUpgradePerCompany` and `OnUpgradePerDatabase` triggers on that codeunit during upgrade. A codeunit without `Subtype = Upgrade` — even one that declares an `OnUpgradePerCompany` trigger — is not an upgrade codeunit, and reviewers ignore it for upgrade concerns. Conversely, any procedure invoked transitively from an `OnUpgrade...` trigger of an upgrade codeunit IS upgrade code regardless of where it lives, and the upgrade rules apply to it. + +## Best Practice + +Place every piece of upgrade logic in a codeunit declared with `Subtype = Upgrade;` and expose entry points via the two triggers `OnUpgradePerCompany` and `OnUpgradePerDatabase`. Helper procedures may live in normal codeunits, but they inherit the upgrade-context rules (guarded reads, no external calls, upgrade tags, etc.) when called from an upgrade trigger. + +See sample: `upgrade-codeunit-subtype.good.al`. + +## Anti Pattern + +Putting upgrade-style logic in a regular codeunit that the platform never invokes during upgrade — for example a normal codeunit with a manually invented "RunUpgrade" procedure that nothing wires to the upgrade pipeline. The migration code will simply not run. + +See sample: `upgrade-codeunit-subtype.bad.al`. diff --git a/microsoft/knowledge/upgrade/use-upgrade-tags-not-version-checks.bad.al b/microsoft/knowledge/upgrade/use-upgrade-tags-not-version-checks.bad.al new file mode 100644 index 0000000..f16946e --- /dev/null +++ b/microsoft/knowledge/upgrade/use-upgrade-tags-not-version-checks.bad.al @@ -0,0 +1,25 @@ +codeunit 50209 "Upgrade Tag Driven" +{ + Subtype = Upgrade; + + trigger OnUpgradePerCompany() + var + AppInfo: ModuleInfo; + begin + NavApp.GetCurrentModuleInfo(AppInfo); + + // Version-coupled branching — breaks when a tenant skips a version. + if AppInfo.DataVersion().Major > 14 then + exit; + + if AppInfo.DataVersion().Major < 14 then + UpgradeFeatureA() + else if AppInfo.DataVersion().Major < 17 then + UpgradeFeatureB() + else + exit; + end; + + local procedure UpgradeFeatureA() begin end; + local procedure UpgradeFeatureB() begin end; +} diff --git a/microsoft/knowledge/upgrade/use-upgrade-tags-not-version-checks.good.al b/microsoft/knowledge/upgrade/use-upgrade-tags-not-version-checks.good.al new file mode 100644 index 0000000..958e3b6 --- /dev/null +++ b/microsoft/knowledge/upgrade/use-upgrade-tags-not-version-checks.good.al @@ -0,0 +1,26 @@ +codeunit 50208 "Upgrade Tag Driven" +{ + Subtype = Upgrade; + + trigger OnUpgradePerCompany() + begin + UpgradeMyFeature(); + end; + + local procedure UpgradeMyFeature() + var + UpgradeTag: Codeunit "Upgrade Tag"; + begin + if UpgradeTag.HasUpgradeTag(MyUpgradeTag()) then + exit; + + // Upgrade work goes here. + + UpgradeTag.SetUpgradeTag(MyUpgradeTag()); + end; + + local procedure MyUpgradeTag(): Code[250] + begin + exit('MS-123456-MyFeatureUpgrade-20240101'); + end; +} diff --git a/microsoft/knowledge/upgrade/use-upgrade-tags-not-version-checks.md b/microsoft/knowledge/upgrade/use-upgrade-tags-not-version-checks.md new file mode 100644 index 0000000..62347d1 --- /dev/null +++ b/microsoft/knowledge/upgrade/use-upgrade-tags-not-version-checks.md @@ -0,0 +1,31 @@ +--- +bc-version: [all] +domain: upgrade +keywords: [upgrade-tag, version-check, dataversion, has-upgrade-tag, set-upgrade-tag, control-flow] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Control upgrade execution with upgrade tags, not version checks + +## Description + +Each piece of upgrade logic must run exactly once per company (or database) across the lifetime of an extension. The platform mechanism for that is the `Upgrade Tag` codeunit: a procedure asks `HasUpgradeTag(MyTag())` at entry, performs its work, then calls `SetUpgradeTag(MyTag())` to record completion. Subsequent upgrades on the same tenant see the tag and skip the work. Hand-rolled `if MyApp.DataVersion().Major < N then ...` chains are the wrong tool: they are version-coupled, accumulate stale branches over time, and break when a tenant skips a version. + +## Best Practice + +Every upgrade procedure starts with a `HasUpgradeTag` guard and ends with `SetUpgradeTag` once the work is committed. Each feature gets its own tag string so features can be re-run independently if needed. + +See sample: `use-upgrade-tags-not-version-checks.good.al`. + +## Anti Pattern + +Branching on `MyApp.DataVersion().Major > N`, or chains of `< N` / `< M` to decide which upgrade step to run. Such code becomes unmaintainable after a few releases and silently does the wrong thing on tenants that skip versions. + +See sample: `use-upgrade-tags-not-version-checks.bad.al`. + +## See also + +- `first-install-dataversion-zero-check.md` — the one situation where reading `DataVersion()` is the right call. +- `register-upgrade-tags-with-subscribers.md` — how to make a tag known to the platform. diff --git a/microsoft/skills/al-code-review.md b/microsoft/skills/review/al-code-review.md similarity index 53% rename from microsoft/skills/al-code-review.md rename to microsoft/skills/review/al-code-review.md index 4f27782..041c0cd 100644 --- a/microsoft/skills/al-code-review.md +++ b/microsoft/skills/review/al-code-review.md @@ -3,23 +3,27 @@ kind: action-skill id: al-code-review version: 1 title: AL code review -description: Reviews AL source changes by composing the AL review leaf skills (performance, security, ...). +description: Reviews AL source changes by composing the AL review leaf skills (performance, security, privacy, upgrade, style, UI). inputs: [pr-diff, file-path] outputs: [findings-report] -bc-version: [26..28] +bc-version: [all] technologies: [al] countries: [w1] application-area: [all] sub-skills: - - microsoft/skills/al-performance-review.md - - microsoft/skills/al-security-review.md + - microsoft/skills/review/al-performance-review.md + - microsoft/skills/review/al-security-review.md + - microsoft/skills/review/al-privacy-review.md + - microsoft/skills/review/al-upgrade-review.md + - microsoft/skills/review/al-style-review.md + - microsoft/skills/review/al-ui-review.md --- # AL code review Reviews AL source changes by composing the leaf AL review skills. This is the canonical reference implementation of a **super-skill** — skill authors writing composed reviews should copy its structure. -`al-code-review` does not evaluate knowledge files directly. It invokes each of its sub-skills against the same task input, collects their findings-reports, and returns a rolled-up findings-report. +`al-code-review` does not evaluate knowledge files directly. It invokes each of its sub-skills against the same task input, collects their findings-reports, and then performs its own **self-review pass** over the diff using the agent's built-in BC and AL knowledge. BCQuality knowledge is an additive layer: anything the sub-skills found is cited from BCQuality, and anything the agent finds on its own is validated against BCQuality (cited if matched, suppressed if contradicted, surfaced as an **agent finding** otherwise). The result is a single rolled-up findings-report that mixes knowledge-backed and agent findings, each clearly tagged via `from-sub-skill`. An orchestrator invokes this skill with either a `pr-diff` (the standard PR-review entry point) or a `file-path` (single-file review). The skill produces a single JSON document conforming to the DO output contract, extended with `sub-results` and — when applicable — `skipped-sub-skills`. @@ -27,10 +31,14 @@ An orchestrator invokes this skill with either a `pr-diff` (the standard PR-revi The sub-skills invoked by this skill are those listed in frontmatter `sub-skills`: -- `microsoft/skills/al-performance-review.md` -- `microsoft/skills/al-security-review.md` +- `microsoft/skills/review/al-performance-review.md` +- `microsoft/skills/review/al-security-review.md` +- `microsoft/skills/review/al-privacy-review.md` +- `microsoft/skills/review/al-upgrade-review.md` +- `microsoft/skills/review/al-style-review.md` +- `microsoft/skills/review/al-ui-review.md` -Additional leaf skills (for example, UX, telemetry, testing) are added by updating the `sub-skills` list. The skill does not discover sub-skills implicitly. +Additional leaf skills (for example, telemetry, testing) are added by updating the `sub-skills` list. The skill does not discover sub-skills implicitly. ## Relevance @@ -52,6 +60,8 @@ The worklist is the list of sub-skills judged relevant by the previous step. Eve ## Action +### Roll up sub-skill findings + For each sub-skill in the worklist: 1. Invoke the sub-skill with the orchestrator's inputs, passing only the subset each sub-skill declares in its `inputs`. @@ -59,7 +69,28 @@ For each sub-skill in the worklist: 3. If the sub-skill's `outcome` is `failed`, stop here for this sub-skill: its findings are not reliable per the DO contract and MUST NOT be copied into the super-skill's top-level `findings[]` or counted in `summary.counts`. 4. Otherwise, append each entry from the sub-skill's `findings[]` to the super-skill's top-level `findings[]`, setting `from-sub-skill` to the sub-skill's `skill.id`. For non-citation findings (those whose `id` is a skill-defined slug rather than a reference path), prefix `id` with `:` to prevent collisions across sub-skills. Other finding fields are preserved. -Aggregate `summary.counts` and `summary.coverage` as the sums across invoked sub-skills whose `outcome` is not `failed`. +### Agent self-review pass + +After the sub-skill rollup, perform a self-review pass against the same task input using the agent's built-in BC and AL knowledge. BCQuality is an **additive** knowledge layer: it augments the agent's review judgement, it does not replace it. The goal of this pass is to surface defects the agent recognises on its own — bugs, anti-patterns, error-handling gaps, AL idioms — that the leaf sub-skills did not catch because no BCQuality knowledge file covers them yet. + +For every candidate the agent identifies in this pass: + +1. **Validate against BCQuality knowledge.** Check the candidate against the knowledge files the sub-skills have already loaded for this task (visible via their `references` and `suppressed` lists in `sub-results`). + - If a BCQuality knowledge file matches the candidate, upgrade it to a knowledge-backed finding: cite the file in `references`, set `id` to the file's path, set `from-sub-skill` to the sub-skill that owns that knowledge domain, and merge with or deduplicate against any sub-skill finding that already covers the same concern at the same location. + - If a BCQuality knowledge file **explicitly contradicts** the candidate (its `## Best Practice` or `## Anti Pattern` says the opposite of what the agent flagged), suppress the candidate and do not surface it. + - Otherwise the candidate has no BCQuality coverage; emit it as an agent finding. +2. **Emit agent finding.** Per DO's *Agent findings* rules: + - `from-sub-skill: "agent"` + - `references: []` + - `id` is a skill-defined slug prefixed with `agent:` (for example, `agent:missing-error-handling-on-http-call`). + - `confidence` capped at `medium`. + - `message` is non-empty and self-contained, describing both the issue and a concrete recommendation. A consumer rendering the finding has no knowledge-file footer to fall back on. + +Leaf sub-skills MUST NOT emit agent findings: their scope is bounded by the knowledge subset they evaluate. The self-review pass is a super-skill responsibility. + +### Summary and rollup + +Aggregate `summary.counts` and `summary.coverage` as the sums across invoked sub-skills whose `outcome` is not `failed`. Agent findings emitted by the super-skill itself contribute to `summary.counts` but not to `summary.coverage` (coverage is a sub-skill worklist metric and is undefined for self-review). `suppressed[]` at the super-skill level remains empty. Knowledge-file-level suppression is reported by each sub-skill within its own entry in `sub-results`. @@ -74,7 +105,7 @@ Output conforms to the DO output contract, extended with `sub-results` and `skip "skill": { "id": "al-code-review", "version": 1 }, "outcome": "completed", "summary": { - "counts": { "blocker": 1, "major": 1, "minor": 1, "info": 1 }, + "counts": { "blocker": 1, "major": 1, "minor": 3, "info": 0 }, "coverage": { "worklist-size": 4, "items-evaluated": 4 } }, "findings": [ @@ -94,43 +125,60 @@ Output conforms to the DO output contract, extended with `sub-results` and `skip "from-sub-skill": "al-performance-review" }, { - "id": "community/knowledge/performance/use-setloadfields.md", - "severity": "info", - "message": "Posting routine iterates ledger entries; consider whether SetLoadFields applies per the linked guidance.", + "id": "community/knowledge/performance/call-setloadfields-before-filters.md", + "severity": "minor", + "message": "SetLoadFields is called after SetRange. Per the referenced guidance the call must come before filters to be folded into the query plan.", + "location": { + "file": "src/Sales/PostingRoutines.Codeunit.al", + "line": 152 + }, "references": [ - { "path": "community/knowledge/performance/use-setloadfields.md" } + { "path": "community/knowledge/performance/call-setloadfields-before-filters.md" } ], - "confidence": "low", + "confidence": "high", "from-sub-skill": "al-performance-review" }, { - "id": "microsoft/knowledge/security/no-plaintext-secrets-in-telemetry.md", + "id": "microsoft/knowledge/security/use-secrettext-for-credentials.md", "severity": "blocker", - "message": "A bearer token is passed to Session.LogMessage as part of the CustomDimensions payload. The referenced guidance documents this as a platform-level data-protection violation.", + "message": "A bearer token is declared as a Text parameter and passed through the HTTP request path as plain text. The referenced guidance requires credentials to flow as SecretText end-to-end.", "location": { "file": "src/Integration/ApiClient.Codeunit.al", "line": 85, "range": { "start-line": 85, "end-line": 89 } }, "references": [ - { "path": "microsoft/knowledge/security/no-plaintext-secrets-in-telemetry.md" } + { "path": "microsoft/knowledge/security/use-secrettext-for-credentials.md" } ], "confidence": "high", "from-sub-skill": "al-security-review" }, { - "id": "microsoft/knowledge/security/avoid-implicit-commit.md", + "id": "microsoft/knowledge/security/never-hardcode-secrets-in-al.md", "severity": "minor", - "message": "An explicit COMMIT inside a posting routine may leave the ledger in an inconsistent state if subsequent steps fail.", + "message": "An API key is assigned from a string literal rather than retrieved from IsolatedStorage or Key Vault at runtime.", "location": { - "file": "src/Sales/PostingRoutines.Codeunit.al", + "file": "src/Integration/ApiClient.Codeunit.al", "line": 201 }, "references": [ - { "path": "microsoft/knowledge/security/avoid-implicit-commit.md" } + { "path": "microsoft/knowledge/security/never-hardcode-secrets-in-al.md" } ], "confidence": "medium", "from-sub-skill": "al-security-review" + }, + { + "id": "agent:missing-error-handling-on-http-client", + "severity": "minor", + "message": "HttpClient.Send is called without inspecting the response status or wrapping the call in a TryFunction. Network or remote-server failures will surface as runtime errors to the user. Recommendation: branch on the HttpResponseMessage.IsSuccessStatusCode and either retry, surface a controlled error, or fall back, depending on the integration's contract.", + "location": { + "file": "src/Integration/ApiClient.Codeunit.al", + "line": 60, + "range": { "start-line": 60, "end-line": 64 } + }, + "references": [], + "confidence": "medium", + "from-sub-skill": "agent" } ], "suppressed": [], @@ -139,7 +187,7 @@ Output conforms to the DO output contract, extended with `sub-results` and `skip "skill": { "id": "al-performance-review", "version": 1 }, "outcome": "completed", "summary": { - "counts": { "blocker": 0, "major": 1, "minor": 0, "info": 1 }, + "counts": { "blocker": 0, "major": 1, "minor": 1, "info": 0 }, "coverage": { "worklist-size": 2, "items-evaluated": 2 } }, "findings": [ @@ -158,13 +206,17 @@ Output conforms to the DO output contract, extended with `sub-results` and `skip "confidence": "high" }, { - "id": "community/knowledge/performance/use-setloadfields.md", - "severity": "info", - "message": "Posting routine iterates ledger entries; consider whether SetLoadFields applies per the linked guidance.", + "id": "community/knowledge/performance/call-setloadfields-before-filters.md", + "severity": "minor", + "message": "SetLoadFields is called after SetRange. Per the referenced guidance the call must come before filters to be folded into the query plan.", + "location": { + "file": "src/Sales/PostingRoutines.Codeunit.al", + "line": 152 + }, "references": [ - { "path": "community/knowledge/performance/use-setloadfields.md" } + { "path": "community/knowledge/performance/call-setloadfields-before-filters.md" } ], - "confidence": "low" + "confidence": "high" } ], "suppressed": [] @@ -178,29 +230,29 @@ Output conforms to the DO output contract, extended with `sub-results` and `skip }, "findings": [ { - "id": "microsoft/knowledge/security/no-plaintext-secrets-in-telemetry.md", + "id": "microsoft/knowledge/security/use-secrettext-for-credentials.md", "severity": "blocker", - "message": "A bearer token is passed to Session.LogMessage as part of the CustomDimensions payload. The referenced guidance documents this as a platform-level data-protection violation.", + "message": "A bearer token is declared as a Text parameter and passed through the HTTP request path as plain text. The referenced guidance requires credentials to flow as SecretText end-to-end.", "location": { "file": "src/Integration/ApiClient.Codeunit.al", "line": 85, "range": { "start-line": 85, "end-line": 89 } }, "references": [ - { "path": "microsoft/knowledge/security/no-plaintext-secrets-in-telemetry.md" } + { "path": "microsoft/knowledge/security/use-secrettext-for-credentials.md" } ], "confidence": "high" }, { - "id": "microsoft/knowledge/security/avoid-implicit-commit.md", + "id": "microsoft/knowledge/security/never-hardcode-secrets-in-al.md", "severity": "minor", - "message": "An explicit COMMIT inside a posting routine may leave the ledger in an inconsistent state if subsequent steps fail.", + "message": "An API key is assigned from a string literal rather than retrieved from IsolatedStorage or Key Vault at runtime.", "location": { - "file": "src/Sales/PostingRoutines.Codeunit.al", + "file": "src/Integration/ApiClient.Codeunit.al", "line": 201 }, "references": [ - { "path": "microsoft/knowledge/security/avoid-implicit-commit.md" } + { "path": "microsoft/knowledge/security/never-hardcode-secrets-in-al.md" } ], "confidence": "medium" } diff --git a/microsoft/skills/al-performance-review.md b/microsoft/skills/review/al-performance-review.md similarity index 88% rename from microsoft/skills/al-performance-review.md rename to microsoft/skills/review/al-performance-review.md index 4b52d21..92933bf 100644 --- a/microsoft/skills/al-performance-review.md +++ b/microsoft/skills/review/al-performance-review.md @@ -6,7 +6,7 @@ title: AL performance review description: Reviews AL source changes against performance guidance from BCQuality. inputs: [pr-diff, file-path] outputs: [findings-report] -bc-version: [26..28] +bc-version: [all] technologies: [al] countries: [w1] application-area: [all] @@ -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. @@ -78,7 +78,7 @@ Output conforms to the DO output contract. A populated example: "skill": { "id": "al-performance-review", "version": 1 }, "outcome": "completed", "summary": { - "counts": { "blocker": 0, "major": 1, "minor": 0, "info": 1 }, + "counts": { "blocker": 0, "major": 1, "minor": 1, "info": 0 }, "coverage": { "worklist-size": 2, "items-evaluated": 2 } }, "findings": [ @@ -97,13 +97,17 @@ Output conforms to the DO output contract. A populated example: "confidence": "high" }, { - "id": "community/knowledge/performance/use-setloadfields.md", - "severity": "info", - "message": "Posting routine iterates ledger entries; consider whether SetLoadFields applies per the linked guidance.", + "id": "community/knowledge/performance/call-setloadfields-before-filters.md", + "severity": "minor", + "message": "SetLoadFields is called after SetRange. Per the referenced guidance the call must come before filters to be folded into the query plan.", + "location": { + "file": "src/Sales/PostingRoutines.Codeunit.al", + "line": 152 + }, "references": [ - { "path": "community/knowledge/performance/use-setloadfields.md" } + { "path": "community/knowledge/performance/call-setloadfields-before-filters.md" } ], - "confidence": "low" + "confidence": "high" } ], "suppressed": [] diff --git a/microsoft/skills/review/al-privacy-review.md b/microsoft/skills/review/al-privacy-review.md new file mode 100644 index 0000000..f78f9af --- /dev/null +++ b/microsoft/skills/review/al-privacy-review.md @@ -0,0 +1,102 @@ +--- +kind: action-skill +id: al-privacy-review +version: 1 +title: AL privacy review +description: Reviews AL source changes against privacy and data-classification guidance from BCQuality. +inputs: [pr-diff, file-path] +outputs: [findings-report] +bc-version: [all] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# AL privacy review + +Reviews AL source changes against the `privacy` 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`. + +An orchestrator invokes this skill with either a `pr-diff` (the standard PR-review entry point) or a `file-path` (single-file review). The skill produces a single JSON document conforming to the DO output contract. + +## Source + +Collect all knowledge files under `*/knowledge/privacy/**/*.md`, across every enabled layer (`/microsoft/`, `/community/`, `/custom/`). Relevance trims the result to the subset that applies. + +## Relevance + +Apply the frontmatter matching rules defined in READ (*Frontmatter matching semantics*) 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]`. +- `countries` — the countries declared in the consuming app's `app.json`. Default to the orchestrator's configured context; if absent, `unknown`. +- `application-area` — the union of application areas declared by the changed objects. Pass the actual set; do not substitute `[all]`. If the area cannot be determined from the changes, the dimension is `unknown`. + +Discard files that are not applicable. Retain conditionally applicable files (any dimension `unknown`) only when the orchestrator's configuration permits them; findings derived from those files MUST have `confidence` no higher than `medium`, AND the finding's `message` MUST name the dimension or dimensions that were unknown. + +## Worklist + +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`, `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. + +Once the candidate worklist is known, resolve layer-precedence conflicts per READ. Drop lower-precedence files whose normative guidance (`## Best Practice` or `## Anti Pattern`) directly contradicts a higher-precedence candidate, and record each dropped file in `suppressed` with `reason: "layer-precedence"`. Files that would have been candidates but are hidden because their layer is disabled in consumer configuration are recorded with `reason: "configuration"`. Files that never became candidates are NOT recorded in `suppressed`. + +When the post-conflict worklist is empty because no applicable privacy knowledge exists, or because configuration suppressed every candidate, emit `outcome: "no-knowledge"`. When the worklist is empty because no applicable privacy knowledge matched the changes, emit `outcome: "completed"` with an empty `findings` array. + +## Action + +For each worklist entry, evaluate the diff against the file's `## Best Practice` and `## Anti Pattern` sections. Emit findings as follows: + +- When the diff contains a clear match for an Anti Pattern, emit a finding with severity `major` or `blocker`, a message summarizing the anti-pattern, `location` pointing to the offending line or range, and a `references` entry pointing to the knowledge file. Use `blocker` only when the knowledge file states the anti-pattern violates a platform-level guarantee (for example, documented telemetry-classification rules or GDPR-adjacent data-handling requirements). When the file does not make such a claim, the ceiling is `major`. +- When the diff contains code that contradicts a Best Practice without being a full anti-pattern, emit `minor` with the same reference shape. +- When the skill cannot detect a violation but the file is clearly applicable to the change, emit `info` citing the file. Repository-wide observations MAY omit `location`. + +Set `confidence` to: + +- `high` when the detection is based on an unambiguous pattern match (identifier, syntax, object type). +- `medium` when detection relies on heuristics or when any frontmatter dimension was `unknown`. +- `low` when the finding is an advisory derived only from applicability. + +Outcome selection: + +- `completed` — the skill evaluated every worklist item; default when the skill finishes normally, including when the resulting `findings` array is empty. +- `no-knowledge` — no applicable privacy knowledge survived Source, Relevance, configuration filtering, and conflict resolution. `findings` is empty. +- `not-applicable` — the task context lacks an AL dimension (no AL changes in the diff, or `technologies` filter rejected the task). +- `partial` — a time or token budget was hit before the worklist was exhausted. `summary.coverage` reflects the evaluated subset; `outcome-reason` explains the cause. +- `failed` — an unrecoverable error occurred. `outcome-reason` is required. + +## Output + +Output conforms to the DO output contract. A populated example: + +```json +{ + "skill": { "id": "al-privacy-review", "version": 1 }, + "outcome": "completed", + "summary": { + "counts": { "blocker": 0, "major": 1, "minor": 0, "info": 0 }, + "coverage": { "worklist-size": 1, "items-evaluated": 1 } + }, + "findings": [ + { + "id": "microsoft/knowledge/privacy/strsubstno-prebuild-breaks-error-telemetry-classification.md", + "severity": "major", + "message": "Error receives a pre-built Text produced by StrSubstNo with customer name and email as arguments. Per the referenced guidance the platform cannot classify or strip PII from an opaque Text and will export the full message to telemetry.", + "location": { + "file": "src/Sales/CustomerValidation.Codeunit.al", + "line": 64, + "range": { "start-line": 60, "end-line": 64 } + }, + "references": [ + { "path": "microsoft/knowledge/privacy/strsubstno-prebuild-breaks-error-telemetry-classification.md" } + ], + "confidence": "high" + } + ], + "suppressed": [] +} +``` diff --git a/microsoft/skills/al-security-review.md b/microsoft/skills/review/al-security-review.md similarity index 82% rename from microsoft/skills/al-security-review.md rename to microsoft/skills/review/al-security-review.md index cecfbd5..c9b326e 100644 --- a/microsoft/skills/al-security-review.md +++ b/microsoft/skills/review/al-security-review.md @@ -6,7 +6,7 @@ title: AL security review description: Reviews AL source changes against security guidance from BCQuality. inputs: [pr-diff, file-path] outputs: [findings-report] -bc-version: [26..28] +bc-version: [all] technologies: [al] countries: [w1] application-area: [all] @@ -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. @@ -83,29 +83,29 @@ Output conforms to the DO output contract. A populated example: }, "findings": [ { - "id": "microsoft/knowledge/security/no-plaintext-secrets-in-telemetry.md", + "id": "microsoft/knowledge/security/use-secrettext-for-credentials.md", "severity": "blocker", - "message": "A bearer token is passed to Session.LogMessage as part of the CustomDimensions payload. The referenced guidance documents this as a platform-level data-protection violation.", + "message": "A bearer token is declared as a Text parameter and passed through the HTTP request path as plain text. The referenced guidance requires credentials to flow as SecretText end-to-end.", "location": { "file": "src/Integration/ApiClient.Codeunit.al", "line": 85, "range": { "start-line": 85, "end-line": 89 } }, "references": [ - { "path": "microsoft/knowledge/security/no-plaintext-secrets-in-telemetry.md" } + { "path": "microsoft/knowledge/security/use-secrettext-for-credentials.md" } ], "confidence": "high" }, { - "id": "microsoft/knowledge/security/avoid-implicit-commit.md", + "id": "microsoft/knowledge/security/never-hardcode-secrets-in-al.md", "severity": "minor", - "message": "An explicit COMMIT inside a posting routine may leave the ledger in an inconsistent state if subsequent steps fail.", + "message": "An API key is assigned from a string literal rather than retrieved from IsolatedStorage or Key Vault at runtime.", "location": { - "file": "src/Sales/PostingRoutines.Codeunit.al", + "file": "src/Integration/ApiClient.Codeunit.al", "line": 201 }, "references": [ - { "path": "microsoft/knowledge/security/avoid-implicit-commit.md" } + { "path": "microsoft/knowledge/security/never-hardcode-secrets-in-al.md" } ], "confidence": "medium" } diff --git a/microsoft/skills/review/al-style-review.md b/microsoft/skills/review/al-style-review.md new file mode 100644 index 0000000..91f40f3 --- /dev/null +++ b/microsoft/skills/review/al-style-review.md @@ -0,0 +1,99 @@ +--- +kind: action-skill +id: al-style-review +version: 1 +title: AL style review +description: Reviews AL source changes against naming, labelling, and code-convention guidance from BCQuality. +inputs: [pr-diff, file-path] +outputs: [findings-report] +bc-version: [all] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# AL style review + +Reviews AL source changes against the `style` 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`. + +Style findings cover AL conventions that CodeCop and similar analyzers partially enforce — label suffixes, API page naming, temporary-variable prefixes, label properties, named invocations, `FieldCaption`/`TableCaption` in user messages, `OptionCaption` pairing, Error-parameter passing, `this` keyword, required parentheses, file-naming. Use together with a formal analyzer; this skill adds BCQuality's remedial-knowledge explanations of why each rule exists. + +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. + +## Source + +Collect all knowledge files under `*/knowledge/style/**/*.md`, across every enabled layer. + +## Relevance + +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]`. +- `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]`. + +Discard files that are not applicable. Retain conditionally applicable files only when the orchestrator's configuration permits them; findings derived from those files MUST have `confidence` no higher than `medium` and MUST name the unknown dimensions in `message`. + +## Worklist + +Narrow the relevant files to the subset that applies to the changes under review. For each relevant file, compute overlap against: + +- Changed AL objects — especially API pages (`PageType = API`), tables and pages declaring Labels/TextConsts, codeunits issuing `Error`/`Message`/`Confirm`, and any file whose name violates the `..al` convention. +- Changed declarations, weighted toward `: Label '...'`, `: TextConst '...'`, temporary record variables, option fields, error-handling call sites, and codeunit-internal method calls. +- Tokens extracted from the diff (`Label`, `TextConst`, `Locked`, `Comment`, `MaxLength`, `temporary`, `OptionMembers`, `OptionCaption`, `APIPublisher`, `APIGroup`, `APIVersion`, `EntityName`, `EntitySetName`, `DelayedInsert`, `FieldCaption`, `TableCaption`, `FieldName`, `TableName`, `Page.RunModal`, `Report.Run`, `this.`, `StrSubstNo`). + +A file enters the candidate worklist when its `keywords` intersect the extracted tokens or its topic matches a changed object or declaration. + +Once the candidate worklist is known, resolve layer-precedence conflicts per READ and record suppressions. + +When the post-conflict worklist is empty because no applicable style knowledge exists, or because configuration suppressed every candidate, emit `outcome: "no-knowledge"`. When the worklist is empty because no applicable style knowledge matched the changes, emit `outcome: "completed"` with an empty `findings` array. + +## Action + +For each worklist entry, evaluate the diff against the file's `## Best Practice` and `## Anti Pattern` sections. Style findings rarely reach `blocker` — reserve it for cases where the knowledge file documents a platform-level requirement (for example, API page property constraints the OData runtime rejects). Most style findings are `minor` or `info`; egregious misuse (`Error` with pre-built Text losing translation and telemetry classification) may reach `major`. + +Set `confidence` to: + +- `high` when the detection is based on an unambiguous pattern match. +- `medium` when detection relies on heuristics or when any frontmatter dimension was `unknown`. +- `low` when the finding is an advisory derived only from applicability. + +Outcome selection: + +- `completed` — the skill evaluated every worklist item. +- `no-knowledge` — no applicable style knowledge survived filtering. +- `not-applicable` — no AL changes in the diff. +- `partial` — a budget was hit before the worklist was exhausted. +- `failed` — an unrecoverable error occurred. + +## Output + +Output conforms to the DO output contract. A populated example: + +```json +{ + "skill": { "id": "al-style-review", "version": 1 }, + "outcome": "completed", + "summary": { + "counts": { "blocker": 0, "major": 0, "minor": 1, "info": 0 }, + "coverage": { "worklist-size": 1, "items-evaluated": 1 } + }, + "findings": [ + { + "id": "microsoft/knowledge/style/apply-approved-label-suffixes.md", + "severity": "minor", + "message": "A Label named Text000 has no approved suffix (Msg/Err/Qst/Tok/Lbl/Txt). Per the referenced CodeCop AA0074 guidance, every Label and TextConst carries a suffix indicating its consuming call.", + "location": { + "file": "src/Sales/PostingRoutines.Codeunit.al", + "line": 42 + }, + "references": [ + { "path": "microsoft/knowledge/style/apply-approved-label-suffixes.md" } + ], + "confidence": "high" + } + ], + "suppressed": [] +} +``` diff --git a/microsoft/skills/review/al-ui-review.md b/microsoft/skills/review/al-ui-review.md new file mode 100644 index 0000000..463ee45 --- /dev/null +++ b/microsoft/skills/review/al-ui-review.md @@ -0,0 +1,99 @@ +--- +kind: action-skill +id: al-ui-review +version: 1 +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, javascript] +countries: [w1] +application-area: [all] +--- + +# AL UI and accessibility 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 — 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. + +## Source + +Collect all knowledge files under `*/knowledge/ui/**/*.md`, across every enabled layer. + +## Relevance + +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]` 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]`. + +Discard files that are not applicable. Retain conditionally applicable files only when the orchestrator's configuration permits them; findings derived from those files MUST have `confidence` no higher than `medium` and MUST name the unknown dimensions in `message`. + +## Worklist + +Narrow the relevant files to the subset that applies to the changes under review. + +- **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. + +Once the candidate worklist is known, resolve layer-precedence conflicts per READ and record suppressions. + +When the post-conflict worklist is empty because no applicable UI knowledge exists, or because configuration suppressed every candidate, emit `outcome: "no-knowledge"`. When the worklist is empty because no applicable UI knowledge matched the page changes, emit `outcome: "completed"` with an empty `findings` array. + +## 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. 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: + +- `high` when the detection is based on an unambiguous pattern match (banned term literal, missing "Specifies" opener on a field tooltip, caption exceeding documented limit). +- `medium` when detection relies on heuristics (judging whether a caption is a noun phrase or a sentence phrase) or when any frontmatter dimension was `unknown`. +- `low` when the finding is an advisory derived only from applicability. + +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, pagecustomization, or control add-in UI files. +- `partial` — a budget was hit before the worklist was exhausted. +- `failed` — an unrecoverable error occurred. + +## Output + +Output conforms to the DO output contract. A populated example: + +```json +{ + "skill": { "id": "al-ui-review", "version": 1 }, + "outcome": "completed", + "summary": { + "counts": { "blocker": 0, "major": 0, "minor": 1, "info": 0 }, + "coverage": { "worklist-size": 1, "items-evaluated": 1 } + }, + "findings": [ + { + "id": "microsoft/knowledge/ui/field-tooltips-start-with-specifies-and-end-with-period.md", + "severity": "minor", + "message": "Field ToolTip is a fragment ('Customer name') — missing the 'Specifies' opener and the terminating period the house-style guidance requires.", + "location": { + "file": "src/Sales/CustomerCard.Page.al", + "line": 58 + }, + "references": [ + { "path": "microsoft/knowledge/ui/field-tooltips-start-with-specifies-and-end-with-period.md" } + ], + "confidence": "high" + } + ], + "suppressed": [] +} +``` diff --git a/microsoft/skills/review/al-upgrade-review.md b/microsoft/skills/review/al-upgrade-review.md new file mode 100644 index 0000000..313694c --- /dev/null +++ b/microsoft/skills/review/al-upgrade-review.md @@ -0,0 +1,101 @@ +--- +kind: action-skill +id: al-upgrade-review +version: 1 +title: AL upgrade review +description: Reviews AL source changes against upgrade-code and migration guidance from BCQuality. +inputs: [pr-diff, file-path] +outputs: [findings-report] +bc-version: [all] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# AL upgrade review + +Reviews AL source changes against the `upgrade` 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`. + +An orchestrator invokes this skill with either a `pr-diff` (the standard PR-review entry point) or a `file-path` (single-file review). Upgrade findings are narrow by design — they apply when the diff touches upgrade codeunits, install codeunits, table schema, enums, or objects under migration namespaces. The skill returns `not-applicable` when none of those apply. + +## Source + +Collect all knowledge files under `*/knowledge/upgrade/**/*.md`, across every enabled layer (`/microsoft/`, `/community/`, `/custom/`). Relevance trims the result to the subset that applies. + +## Relevance + +Apply the frontmatter matching rules defined in READ (*Frontmatter matching semantics*) 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]`. +- `countries` — the countries declared in the consuming app's `app.json`. Default to the orchestrator's configured context; if absent, `unknown`. +- `application-area` — the union of application areas declared by the changed objects. Pass the actual set; do not substitute `[all]`. If the area cannot be determined from the changes, the dimension is `unknown`. + +Discard files that are not applicable. Retain conditionally applicable files (any dimension `unknown`) only when the orchestrator's configuration permits them; findings derived from those files MUST have `confidence` no higher than `medium`, AND the finding's `message` MUST name the dimension or dimensions that were unknown. + +## Worklist + +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`, `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. + +Once the candidate worklist is known, resolve layer-precedence conflicts per READ. Drop lower-precedence files whose normative guidance directly contradicts a higher-precedence candidate, and record each dropped file in `suppressed` with `reason: "layer-precedence"`. Files suppressed by configuration are recorded with `reason: "configuration"`. + +When the post-conflict worklist is empty because no applicable upgrade knowledge exists, or because configuration suppressed every candidate, emit `outcome: "no-knowledge"`. When the worklist is empty because no applicable upgrade knowledge matched the changes, emit `outcome: "completed"` with an empty `findings` array. + +## Action + +For each worklist entry, evaluate the diff against the file's `## Best Practice` and `## Anti Pattern` sections. Emit findings as follows: + +- When the diff contains a clear match for an Anti Pattern, emit a finding with severity `major` or `blocker`, a message summarizing the anti-pattern, `location` pointing to the offending line or range, and a `references` entry pointing to the knowledge file. Use `blocker` for irreversible data corruption (enum-ordinal shift, unguarded reads that abort the upgrade) and for changes that would ship to customers without a migration path (new InitValue on an existing table without upgrade code). +- When the diff contains code that contradicts a Best Practice without being a full anti-pattern, emit `minor` with the same reference shape. +- When the skill cannot detect a violation but the file is clearly applicable to the change, emit `info` citing the file. + +Set `confidence` to: + +- `high` when the detection is based on an unambiguous pattern match. +- `medium` when detection relies on heuristics or when any frontmatter dimension was `unknown`. +- `low` when the finding is an advisory derived only from applicability. + +Outcome selection: + +- `completed` — the skill evaluated every worklist item. +- `no-knowledge` — no applicable upgrade knowledge survived filtering. +- `not-applicable` — the diff touches no upgrade, install, schema, or enum surface. +- `partial` — a budget was hit before the worklist was exhausted. +- `failed` — an unrecoverable error occurred. + +## Output + +Output conforms to the DO output contract. A populated example: + +```json +{ + "skill": { "id": "al-upgrade-review", "version": 1 }, + "outcome": "completed", + "summary": { + "counts": { "blocker": 1, "major": 0, "minor": 0, "info": 0 }, + "coverage": { "worklist-size": 1, "items-evaluated": 1 } + }, + "findings": [ + { + "id": "microsoft/knowledge/upgrade/enum-changes-must-be-additive-at-the-end.md", + "severity": "blocker", + "message": "A new enum value was inserted at ordinal 1, shifting every subsequent value by one. Rows that store the old ordinal 1 will silently resolve to the new value. Per the referenced guidance, enum values must be appended at the end.", + "location": { + "file": "src/Shared/OrderStatus.Enum.al", + "line": 7 + }, + "references": [ + { "path": "microsoft/knowledge/upgrade/enum-changes-must-be-additive-at-the-end.md" } + ], + "confidence": "high" + } + ], + "suppressed": [] +} +``` diff --git a/skills/do.md b/skills/do.md index 0fbd430..fd126bf 100644 --- a/skills/do.md +++ b/skills/do.md @@ -133,6 +133,18 @@ An empty `findings` array with `outcome: completed` means the skill ran and foun When a super-skill rolls up a non-citation finding from a sub-skill (an `id` that is a slug, not a path), the super-skill MUST prefix the `id` with `:` to avoid collisions across sub-skills (for example, a slug `missing-test` from `al-security-review` becomes `al-security-review:missing-test`). Citation-based findings are already globally unique through their repo-relative path and MUST NOT be rewritten. +**Agent findings.** A super-skill MAY emit findings that the agent identified through its own reasoning rather than from a BCQuality knowledge file. BCQuality is an **additive** knowledge layer: it augments the agent's pre-existing review judgement, it does not replace it. An agent finding is encoded by: + +- `from-sub-skill: "agent"` — the canonical marker. Use this exact value; do not invent equivalents. +- `references: []` — required. An agent finding has no knowledge-file citation by definition; if a citation existed, the finding would be a knowledge-backed finding instead. +- `id` — a skill-defined slug, prefixed with `agent:` (mirroring the `:` rule). For example, `agent:obsolete-find-signature`. +- `confidence` — capped at `medium`. Without a knowledge-file citation there is no authoritative basis for `high` confidence. +- `message` — non-empty and self-contained. It MUST describe the issue and a concrete recommendation, since a consumer rendering the finding has no knowledge-file footer to fall back on. + +Agent findings are emitted **only by super-skills** (the `al-code-review` super-skill is the canonical example). Leaf sub-skills MUST NOT emit agent findings: a leaf's job is to evaluate one knowledge subset, and a finding it cannot cite from that subset is out of scope for it. Before emitting an agent finding, a super-skill MUST validate the candidate against the BCQuality knowledge it has already loaded for the task — if a knowledge file matches, the candidate is upgraded to a knowledge-backed finding (and merged or deduplicated against any sub-skill output that already covers the same concern); if a knowledge file explicitly contradicts the candidate, it is suppressed. + +Consumers that render output MAY treat agent findings differently from knowledge-backed findings (for example, by labelling them and routing them to a separate review domain). The `from-sub-skill: "agent"` marker is the contract they rely on. + **`findings[].severity`** — see the taxonomy below. **`findings[].message`** — human-readable explanation of the finding. Single short paragraph. No markdown formatting assumptions. @@ -150,11 +162,11 @@ Findings without a `location` are permitted (for example, repository-wide observ - `path` (required) — repo-relative path to the knowledge file, forward slashes. - `sha` (optional) — commit SHA the skill read when producing the finding. Consumers SHOULD include `sha` when the skill was invoked with a specific repo state. -The first reference is the **primary** reference: the knowledge file the finding most directly cites. Additional references provide supporting context and are not ranked. `references` MAY be empty for findings the skill generates without a knowledge-file citation. +The first reference is the **primary** reference: the knowledge file the finding most directly cites. Additional references provide supporting context and are not ranked. `references` MAY be empty only for **agent findings** (see the `findings[].id` section above for the full encoding); any other finding MUST have at least one reference. **`findings[].confidence`** — the skill's confidence that the finding is a true positive, given the evidence it evaluated. Not applicability confidence, not severity confidence. Values: `high`, `medium`, `low`. -**`findings[].from-sub-skill`** — optional. Set only by super-skills. The `skill.id` of the sub-skill that produced the finding. Absent on findings produced directly by the emitting skill. +**`findings[].from-sub-skill`** — optional. Set only by super-skills. The `skill.id` of the sub-skill that produced the finding, or the literal string `"agent"` for an agent finding the super-skill produced from its own reasoning. Absent on findings produced directly by a leaf skill. **`suppressed`** — MUST list every knowledge file that was discarded due to layer precedence or consumer configuration, whenever that file would otherwise have contributed to the worklist. Each entry contains: diff --git a/skills/entry.md b/skills/entry.md index f1c5798..d63ca2e 100644 --- a/skills/entry.md +++ b/skills/entry.md @@ -76,7 +76,7 @@ Emit a single JSON document conforming to the output contract below. Entry does "skill": { "id": "al-code-review", "version": 1, - "path": "microsoft/skills/al-code-review.md" + "path": "microsoft/skills/review/al-code-review.md" }, "rationale": "string", "inputs": ["pr-diff"] @@ -141,14 +141,14 @@ Populated example (PR review on a repo where only `al-performance-review` is ena "outcome": "routed", "dispatch": [ { - "skill": { "id": "al-performance-review", "version": 1, "path": "microsoft/skills/al-performance-review.md" }, + "skill": { "id": "al-performance-review", "version": 1, "path": "microsoft/skills/review/al-performance-review.md" }, "rationale": "Goal 'review pull request' matched; inputs-available contains pr-diff.", "inputs": ["pr-diff"] } ], "skipped": [ - { "skill": { "id": "al-code-review", "path": "microsoft/skills/al-code-review.md" }, "reason": "configuration" }, - { "skill": { "id": "al-security-review", "path": "microsoft/skills/al-security-review.md" }, "reason": "configuration" } + { "skill": { "id": "al-code-review", "path": "microsoft/skills/review/al-code-review.md" }, "reason": "configuration" }, + { "skill": { "id": "al-security-review", "path": "microsoft/skills/review/al-security-review.md" }, "reason": "configuration" } ] } ``` diff --git a/skills/read.md b/skills/read.md index b9a1562..08bce2c 100644 --- a/skills/read.md +++ b/skills/read.md @@ -26,7 +26,7 @@ A file that violates any of these rules is invalid and MUST be skipped by consum ```yaml --- -bc-version: [26, 27, 28] # or the range shorthand [26..28] +bc-version: [all] # or [26, 27, 28] or the range shorthand [26..28] domain: performance keywords: [query, filtering, partial] technologies: [al] @@ -39,12 +39,13 @@ All six fields are required. Missing or empty fields invalidate the file. ### Fields -**`bc-version`** — Array. The Business Central major versions this file applies to. Two forms are accepted: +**`bc-version`** — Array. The Business Central major versions this file applies to. Three forms are accepted: +- Universal sentinel: `[all]` means the guidance applies to every BC version and matches any target. - Explicit list: `[26, 27, 28]`. - Range shorthand: `[26..28]` means every integer from 26 through 28 inclusive. -Consumers MUST expand ranges to the full set before comparison. +`[all]` is mutually exclusive with explicit versions; do not combine. Consumers MUST expand ranges to the full set before comparison. **`domain`** — String. A single domain tag that places the file within a broader area of concern. Standard values include `performance`, `security`, `ux`, `telemetry`, `testing`, `api`, `pipelines`, `finance`, `supply-chain`, `manufacturing`, `jobs`. New domains may be introduced by contributors; no closed enumeration is enforced at the schema level. Consumers MUST treat unknown domains as valid. @@ -93,7 +94,7 @@ Conflict detection is the consumer's responsibility; BCQuality does not enforce When a consumer filters or matches files against a task context, these rules apply: -- **`bc-version`** — the target BC version MUST be an element of the file's expanded `bc-version` set. Range shorthand (`[26..28]`) MUST be expanded before comparison. +- **`bc-version`** — the file matches if its set is `[all]`, or if the target BC version is an element of the file's expanded `bc-version` set. Range shorthand (`[26..28]`) MUST be expanded before comparison. - **`technologies`** — non-empty intersection between the task's technologies and the file's technologies. There is no sentinel for this field. - **`countries`** — the file matches if its set contains `w1`, or if there is a non-empty intersection with the task's countries. - **`application-area`** — the file matches if its set contains `all`, or if there is a non-empty intersection with the task's application areas. @@ -104,7 +105,7 @@ A file is **applicable** to a task when all four rules match. Applicability is a A task context may omit one or more dimensions (for example, a skill invoked against a raw file path with no known target BC version). For any omitted dimension: -- If the file's value for that dimension is a universal sentinel (`w1` for countries, `all` for application-area), the rule matches. +- If the file's value for that dimension is a universal sentinel (`all` for bc-version, `w1` for countries, `all` for application-area), the rule matches. - Otherwise the rule is treated as **unknown**, not as a match and not as a failure. A file with any `unknown` rule is **conditionally applicable**. A consumer MAY include conditionally applicable files in the worklist; if it does, every finding derived from such a file MUST have `confidence` no higher than `medium` and MUST record the unknown dimensions in the finding's `message`. A consumer MAY be configured to exclude conditionally applicable files entirely. diff --git a/skills/write.md b/skills/write.md index 22af773..6fe2eee 100644 --- a/skills/write.md +++ b/skills/write.md @@ -43,7 +43,7 @@ Knowledge files do not contain code. Samples live as **sibling files** next to t ## Choosing frontmatter values -**`bc-version`.** Claim only the versions you have evidence for. If the guidance is known to apply from BC 24 onward and you have tested against 26–28, write `[26..28]`, not `[24..28]`. Under-claim; a future contributor can widen the range. +**`bc-version`.** Default to `[all]` when the guidance is universal — a BC language pattern, a property on a long-standing platform type, a CodeCop rule, or a platform behaviour that has not changed across versions. Use an explicit list or range (`[26, 27, 28]`, `[26..28]`) only when the guidance is tied to a version-gated API, a deprecation, or platform behaviour that genuinely differs across versions. Most knowledge files should be `[all]`; reach for a range only with a concrete reason. **`domain`.** Pick one. If two fit, the file is probably two concerns. If no existing domain fits, introduce a new one — domains are open. Prefer existing domains when they are a reasonable fit, to keep retrieval predictable.