diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json
new file mode 100644
index 0000000..6aa3f8a
--- /dev/null
+++ b/.claude-plugin/marketplace.json
@@ -0,0 +1,15 @@
+{
+ "name": "bcquality",
+ "owner": {
+ "name": "microsoft/BCQuality",
+ "url": "https://github.com/microsoft/BCQuality"
+ },
+ "plugins": [
+ {
+ "name": "bcquality",
+ "source": "./",
+ "description": "Business Central AL quality knowledge base and review skills, packaged as an installable plugin. Ships the entire BCQuality tree (skills, knowledge, tools) so the Entry routing protocol runs against the installed clone.",
+ "version": "0.1.0"
+ }
+ ]
+}
diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json
new file mode 100644
index 0000000..bacea2a
--- /dev/null
+++ b/.claude-plugin/plugin.json
@@ -0,0 +1,9 @@
+{
+ "name": "bcquality",
+ "description": "Quality skills and knowledge for Business Central development. Exposes a review bridge skill that drives the BCQuality Entry protocol over the installed knowledge base.",
+ "version": "0.1.0",
+ "author": {
+ "name": "microsoft/BCQuality",
+ "url": "https://github.com/microsoft/BCQuality"
+ }
+}
diff --git a/.github/custom-layer-autoclose.md b/.github/custom-layer-autoclose.md
new file mode 100644
index 0000000..f0b059e
--- /dev/null
+++ b/.github/custom-layer-autoclose.md
@@ -0,0 +1,31 @@
+Hey @{{AUTHOR}} π
+
+First off β thank you for jumping in and experimenting! It's awesome to see people pushing on the framework. π
+
+That said, let me gently redirect you, because I think there's a small but important misunderstanding about how the `custom` layer is meant to work:
+
+The `custom` layer in *this* repo isn't a destination for PRs β it's the designated sandbox inside **your own fork**. Think of it as the "your timeline" branch of the multiverse π: this repo is canon, your fork is where you get to remix the lore without needing anyone's approval. That's the whole point of the layer existing β so you *don't* have to upstream your team-specific or experimental work.
+
+The intended workflow is:
+
+1. π΄ **Fork** BCQuality to your own GitHub account
+2. Clone *your fork* locally
+3. Drop your custom agents and knowledge into the `custom` layer **there**
+4. Commit and push to your fork β no PR back to upstream needed for custom stuff
+
+That way you get full control, your changes survive upstream updates cleanly, and you can pull in new core releases from this repo whenever you want. β¨
+
+**Now β here's the fun part:** if while building out your fork you discover knowledge, patterns, or agents that you think would genuinely benefit *everyone* using BCQuality (not just your team), that's exactly what the `/community` layer is for! π PRs to `/community` here in the upstream repo are absolutely welcome and encouraged β it's how the collective hive mind π§ levels up. So please: tinker in your fork, and when you strike gold that's worth sharing, send it our way via `/community`.
+
+Going to close this PR for now (since it's targeting `custom` rather than `/community`), but please don't read it as a "no" β it's a "yes, but let's route it correctly." π Happy to help if you hit any snags spinning up your fork, and genuinely looking forward to seeing what you contribute to `/community` down the line.
+
+
+Files in this PR that triggered the auto-close
+
+{{FILES}}
+
+
+May your merges be conflict-free. π
+
+---
+π€ This PR was closed automatically by the `Guard custom layer` workflow because it adds or changes content under `/custom/`. If you were only updating the template (`custom/README.md` or a `.gitkeep`), a maintainer can re-open it. If you think this was closed in error, just comment here.
diff --git a/.github/new-top-level-flag.md b/.github/new-top-level-flag.md
new file mode 100644
index 0000000..3d297ea
--- /dev/null
+++ b/.github/new-top-level-flag.md
@@ -0,0 +1,10 @@
+
+π Heads up @{{AUTHOR}} β and cc maintainers β this PR introduces **new top-level entries** that aren't part of BCQuality's known repository structure:
+
+{{ENTRIES}}
+
+This isn't a block β just a flag. π© New top-level folders and files are *usually* unintended (a stray export, a tool's scratch dir, or content that meant to land inside an existing layer like `/community/knowledge/`). BCQuality keeps a deliberately small root: `.github/`, `community/`, `custom/`, `microsoft/`, `skills/`, and `tools/`, plus a handful of root docs.
+
+**If this was intentional** and the new entry genuinely belongs at the repo root, a maintainer can review and merge as normal β no action needed beyond a quick sanity check. **If it wasn't**, please move the content into the right existing layer (or drop it) and push an update. π
+
+A maintainer will take a look before merging.
diff --git a/.github/workflows/flag-new-top-level.yml b/.github/workflows/flag-new-top-level.yml
new file mode 100644
index 0000000..c3a5c2d
--- /dev/null
+++ b/.github/workflows/flag-new-top-level.yml
@@ -0,0 +1,108 @@
+name: Flag new top-level entries
+
+# BCQuality keeps a deliberately small repository root. New top-level folders
+# or files are almost always unintended β a stray export, a tool's scratch
+# directory, or content that meant to land inside an existing layer (e.g.
+# /community/knowledge/). PR #55 leaked exactly this kind of stray folder.
+#
+# Unlike the custom-layer guard, this workflow does NOT close the PR. It only
+# posts a single advisory comment so a maintainer (and the author) can eyeball
+# the addition. It reads the PR's file LIST via the API and never checks out or
+# runs PR code.
+
+on:
+ pull_request_target:
+ types: [opened, reopened, synchronize]
+
+permissions:
+ contents: read
+ pull-requests: write
+ issues: write
+
+jobs:
+ flag:
+ if: github.repository == 'microsoft/BCQuality'
+ runs-on: ubuntu-latest
+ steps:
+ - name: Check out repository
+ uses: actions/checkout@v4
+ with:
+ sparse-checkout: |
+ .github/new-top-level-flag.md
+ sparse-checkout-cone-mode: false
+
+ - name: Flag unexpected new top-level entries
+ uses: actions/github-script@v7
+ with:
+ script: |
+ const fs = require('fs');
+
+ // Known, intended repository root. Anything else added at the root
+ // is flagged for a human to eyeball.
+ const ALLOWED_DIRS = new Set([
+ '.claude-plugin', '.github', 'community', 'custom', 'microsoft', 'skills', 'tools',
+ ]);
+ const ALLOWED_FILES = new Set([
+ '.gitignore', 'CODEOWNERS', 'LICENSE', 'README.md',
+ 'SECURITY.md', 'agent-consumption.md',
+ ]);
+
+ const MARKER = '';
+ const { owner, repo } = context.repo;
+ const prNumber = context.payload.pull_request.number;
+
+ const files = await github.paginate(github.rest.pulls.listFiles, {
+ owner, repo, pull_number: prNumber, per_page: 100,
+ });
+
+ // Only consider newly-added paths β a new top-level entry can only
+ // appear via an added file.
+ const added = files
+ .filter((f) => f.status === 'added')
+ .map((f) => f.filename);
+
+ const newDirs = new Set();
+ const newFiles = new Set();
+ for (const p of added) {
+ const slash = p.indexOf('/');
+ if (slash === -1) {
+ // Top-level file.
+ if (!ALLOWED_FILES.has(p)) newFiles.add(p);
+ } else {
+ // Top-level directory.
+ const dir = p.slice(0, slash);
+ if (!ALLOWED_DIRS.has(dir)) newDirs.add(dir);
+ }
+ }
+
+ if (newDirs.size === 0 && newFiles.size === 0) {
+ core.info('No unexpected new top-level entries. Nothing to flag.');
+ return;
+ }
+
+ // Idempotency: don't re-flag on every synchronize.
+ const comments = await github.paginate(github.rest.issues.listComments, {
+ owner, repo, issue_number: prNumber, per_page: 100,
+ });
+ if (comments.some((c) => c.body && c.body.includes(MARKER))) {
+ core.info('Already flagged on this PR. Skipping duplicate comment.');
+ return;
+ }
+
+ const lines = [];
+ for (const d of [...newDirs].sort()) lines.push(`- π \`${d}/\` (new top-level folder)`);
+ for (const f of [...newFiles].sort()) lines.push(`- π \`${f}\` (new top-level file)`);
+ const entries = lines.join('\n');
+
+ core.warning(`Unexpected new top-level entries: ${[...newDirs, ...newFiles].join(', ')}`);
+
+ let body = fs.readFileSync('.github/new-top-level-flag.md', 'utf8');
+ body = body
+ .replace(/{{AUTHOR}}/g, context.payload.pull_request.user.login)
+ .replace(/{{ENTRIES}}/g, entries);
+
+ await github.rest.issues.createComment({
+ owner, repo, issue_number: prNumber, body,
+ });
+
+ core.info(`Flagged PR #${prNumber}.`);
diff --git a/.github/workflows/guard-custom-layer.yml b/.github/workflows/guard-custom-layer.yml
new file mode 100644
index 0000000..c061389
--- /dev/null
+++ b/.github/workflows/guard-custom-layer.yml
@@ -0,0 +1,88 @@
+name: Guard custom layer
+
+# The /custom/ layer is a template: in upstream microsoft/BCQuality it stays
+# empty by default (README.md + .gitkeep placeholders only). Custom knowledge
+# and skills are partner/customer-specific and belong in a fork, never upstream.
+#
+# This workflow auto-closes any PR that adds or changes content under /custom/
+# (anything beyond the allowed template files). It runs only on the upstream
+# repo, so forks that legitimately populate /custom/ are unaffected.
+#
+# pull_request_target is required so the workflow runs with a token that can
+# comment on and close the PR (including PRs opened from forks). It only reads
+# the PR's file LIST via the API and never checks out or executes PR code, so
+# the elevated token is not exposed to untrusted content.
+
+on:
+ pull_request_target:
+ types: [opened, reopened, synchronize]
+
+permissions:
+ contents: read
+ pull-requests: write
+ issues: write
+
+jobs:
+ guard:
+ # Never run on forks β a fork's /custom/ content is exactly what's supposed
+ # to live there.
+ if: github.repository == 'microsoft/BCQuality'
+ runs-on: ubuntu-latest
+ steps:
+ - name: Check out repository
+ uses: actions/checkout@v4
+ with:
+ sparse-checkout: |
+ .github/custom-layer-autoclose.md
+ sparse-checkout-cone-mode: false
+
+ - name: Close PR if it touches the custom layer
+ uses: actions/github-script@v7
+ with:
+ script: |
+ const fs = require('fs');
+
+ // Files under custom/ that ARE allowed to change (the template seed).
+ const ALLOWED = new Set([
+ 'custom/README.md',
+ ]);
+ // Any .gitkeep under custom/ is also allowed.
+ const isAllowed = (p) =>
+ ALLOWED.has(p) || /^custom\/.*\.gitkeep$/.test(p) || p === 'custom/.gitkeep';
+
+ const { owner, repo } = context.repo;
+ const prNumber = context.payload.pull_request.number;
+
+ const files = await github.paginate(github.rest.pulls.listFiles, {
+ owner, repo, pull_number: prNumber, per_page: 100,
+ });
+
+ // Offending = added/modified/renamed/copied/changed paths under custom/
+ // that are not template files. (We ignore pure deletions.)
+ const offending = files
+ .filter((f) => f.status !== 'removed')
+ .map((f) => f.filename)
+ .filter((p) => p.startsWith('custom/') && !isAllowed(p));
+
+ if (offending.length === 0) {
+ core.info('No disallowed /custom/ changes found. Nothing to do.');
+ return;
+ }
+
+ core.warning(`PR #${prNumber} touches the custom layer: ${offending.join(', ')}`);
+
+ const fileList = offending.map((p) => `- \`${p}\``).join('\n');
+ let body = fs.readFileSync('.github/custom-layer-autoclose.md', 'utf8');
+ body = body
+ .replace(/{{AUTHOR}}/g, context.payload.pull_request.user.login)
+ .replace(/{{FILES}}/g, fileList);
+
+ await github.rest.issues.createComment({
+ owner, repo, issue_number: prNumber, body,
+ });
+
+ await github.rest.pulls.update({
+ owner, repo, pull_number: prNumber, state: 'closed',
+ });
+
+ core.info(`Closed PR #${prNumber}.`);
diff --git a/.github/workflows/release-version.yml b/.github/workflows/release-version.yml
new file mode 100644
index 0000000..36f80f3
--- /dev/null
+++ b/.github/workflows/release-version.yml
@@ -0,0 +1,69 @@
+# Cuts a BCQuality content release on demand (roughly monthly), NOT on every
+# commit. Run this workflow manually once the `main` content is ready, and choose
+# whether to bump the minor (usual periodic content update) or the major
+# (breaking change).
+#
+# The version is a `major.minor` value derived from existing git tags β there is
+# no VERSION file. The minor is a monotonic counter: it only ever increments and
+# never resets, even across a major bump, so it uniquely identifies a release.
+# This workflow computes the next version and tags the current commit as
+# `v{major}.{minor}`.
+
+name: Release version
+
+on:
+ workflow_dispatch:
+ inputs:
+ bump:
+ description: Which part to bump
+ type: choice
+ options:
+ - minor
+ - major
+ default: minor
+
+# Only tag creation needs write.
+permissions:
+ contents: write
+
+concurrency:
+ group: release-version
+ cancel-in-progress: false
+
+jobs:
+ release:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+
+ - name: Compute and tag release
+ shell: bash
+ run: |
+ git fetch --tags --force --quiet
+ tags="$(git tag -l | grep -E '^v[0-9]+\.[0-9]+$' || true)"
+
+ if [[ -z "$tags" ]]; then
+ # First release.
+ major=1
+ minor=0
+ else
+ latest_major="$(printf '%s\n' "$tags" | sed -E 's/^v([0-9]+)\..*/\1/' | sort -n | tail -1)"
+ latest_minor="$(printf '%s\n' "$tags" | sed -E 's/^v[0-9]+\.([0-9]+)$/\1/' | sort -n | tail -1)"
+ minor=$(( latest_minor + 1 )) # monotonic, never resets
+ if [[ "${{ inputs.bump }}" == "major" ]]; then
+ major=$(( latest_major + 1 ))
+ else
+ major="$latest_major"
+ fi
+ fi
+
+ tag="v${major}.${minor}"
+ if git rev-parse -q --verify "refs/tags/${tag}" >/dev/null; then
+ echo "::error::Tag ${tag} already exists"
+ exit 1
+ fi
+ git tag "$tag" "${{ github.sha }}"
+ git push origin "$tag"
+ echo "Released BCQuality ${tag} at ${{ github.sha }}"
diff --git a/README.md b/README.md
index ddab523..6abf98a 100644
--- a/README.md
+++ b/README.md
@@ -136,6 +136,18 @@ For the end-to-end flow β from orchestrator trigger through to how output reac
β βββ /skills/
```
+## Versioning
+
+BCQuality content is released on demand β roughly monthly, not on every commit. A
+release is a `major.minor` value derived from git tags, cut manually via the
+`Release version` workflow: pick whether to bump the minor or the major, and it
+computes the next version and tags the current `main` as `v{major}.{minor}`.
+
+- Bump the **minor** for the usual periodic content update; bump the **major**
+ only for a breaking change.
+- The minor is a **monotonic counter** β it only ever increments and never
+ resets, even across a major bump β so it uniquely identifies a release.
+
## Contributing
Contributions are welcome. Before submitting a PR:
diff --git a/agent-consumption.md b/agent-consumption.md
index 0d2e5b4..37b684f 100644
--- a/agent-consumption.md
+++ b/agent-consumption.md
@@ -21,7 +21,7 @@ flowchart LR
E -->|3 dispatch record| A
A -->|4 invoke dispatched skill| S[Action skill
e.g. al-code-review]
S -->|5 execute| P[Source β Relevance
β Worklist β Action
reading READ Β· DO on demand]
- P -->|6 emit| R[Findings Β· References
Β· Confidence]
+ P -->|6 emit| R[Findings Β· Domain labels
Β· References Β· Confidence]
R -->|7 integrate| O
```
@@ -65,6 +65,7 @@ The output contract is defined in the DO meta-skill so that every action skill
- **Outcome** β `completed`, `not-applicable`, `no-knowledge`, `partial`, or `failed`. An orchestrator can distinguish a clean run from a no-op from a failure without guessing.
- **Findings** β what the skill observed (severity, message, optional location).
+- **Domain** β the producer-owned, human-readable display label on each review finding.
- **References** β structured objects (`path` plus optional commit `sha`) pointing to the knowledge files that informed each finding.
- **Confidence** β per-finding evidence strength.
- **Suppressed** β knowledge files that were discarded by layer precedence or configuration, so reviewers can see what was overridden.
@@ -78,12 +79,12 @@ The orchestrator turns findings into PR comments, build gates, or IDE diagnostic
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.
+- **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. Leaf sub-skills set `domain` to their human-readable display label, and super-skills preserve it verbatim during rollup.
+- **Agent findings** carry an empty `references: []`, use a slug `id` prefixed `agent:`, and have `confidence` capped at `medium`. A leaf can emit one strictly within its own domain and uses that leaf's display label. A super-skill can emit a cross-cutting agent finding with `from-sub-skill: "agent"` and `domain: "Agent"`. 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.
+Before a 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 relevant existing 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.
+Orchestrators SHOULD render a non-empty `domain` value verbatim and MUST tolerate its absence for reports from older producers. They MAY render knowledge-backed and agent findings differently and MAY apply independent severity floors; `references: []` and the `agent:` id prefix distinguish agent findings, while `from-sub-skill: "agent"` identifies those emitted by the super-skill itself.
## Why this architecture
diff --git a/community/knowledge/performance/choose-maintainsiftindex-by-read-write-ratio.md b/community/knowledge/performance/choose-maintainsiftindex-by-read-write-ratio.md
deleted file mode 100644
index f320dbc..0000000
--- a/community/knowledge/performance/choose-maintainsiftindex-by-read-write-ratio.md
+++ /dev/null
@@ -1,26 +0,0 @@
----
-bc-version: [all]
-domain: performance
-keywords: [maintainsiftindex, sift, calcsums, flowfield, write-cost]
-technologies: [al]
-countries: [w1]
-application-area: [all]
----
-
-# Choose MaintainSIFTIndex by read-write ratio
-
-> Contributions welcome β open a PR to refine or extend this article.
-
-## Description
-
-`MaintainSIFTIndex` on a key decides whether the SIFT aggregate structure is updated on every `INSERT`, `MODIFY`, and `DELETE` that touches the key's fields. With `Yes`, `CalcSums` and FlowField reads are immediate β but every write pays the cost of updating the aggregate. With `No`, writes are cheaper but the first aggregate read after a change has to rebuild. Neither value is universally correct; the right choice depends on how often the aggregate is read versus how often the underlying rows are written.
-
-## Best Practice
-
-Measure read-to-write ratios for the key's SIFT fields under realistic workloads. Set `MaintainSIFTIndex = Yes` only on keys whose aggregates are read far more often than the rows are written (reporting keys on reference tables, dashboards). Set `No` on keys whose rows are written heavily and whose aggregates are read rarely (transactional ledger entries, import-staging tables).
-
-See sample: `choose-maintainsiftindex-by-read-write-ratio.good.al`.
-
-## Anti Pattern
-
-Leaving `MaintainSIFTIndex = Yes` on every key by reflex or convenience. On write-heavy tables the cumulative cost turns every INSERT or MODIFY into several additional aggregate updates, and the impact compounds in batch imports and posting routines β often without any code-review signal that the property is the cause.
diff --git a/community/knowledge/performance/load-common-fields-before-branching-on-case.bad.al b/community/knowledge/performance/load-common-fields-before-branching-on-case.bad.al
deleted file mode 100644
index 22d6de9..0000000
--- a/community/knowledge/performance/load-common-fields-before-branching-on-case.bad.al
+++ /dev/null
@@ -1,23 +0,0 @@
-codeunit 50100 "Sales Document Processor"
-{
- procedure ProcessDocument(var SalesHeader: Record "Sales Header")
- begin
- // Single top-level load pulls every field any branch might touch.
- // Order records pay for Posting Date and Amount Including VAT that
- // only the Invoice branch reads, and vice versa.
- SalesHeader.SetLoadFields(
- "Document Type", "No.", "Sell-to Customer No.",
- "Order Date", "Shipment Date", "Completely Shipped",
- "Posting Date", "Amount Including VAT");
-
- case SalesHeader."Document Type" of
- SalesHeader."Document Type"::Order:
- ProcessOrder(SalesHeader);
- SalesHeader."Document Type"::Invoice:
- ProcessInvoice(SalesHeader);
- end;
- end;
-
- local procedure ProcessOrder(var SalesHeader: Record "Sales Header") begin end;
- local procedure ProcessInvoice(var SalesHeader: Record "Sales Header") begin end;
-}
diff --git a/community/knowledge/performance/load-common-fields-before-branching-on-case.good.al b/community/knowledge/performance/load-common-fields-before-branching-on-case.good.al
deleted file mode 100644
index ec70b4c..0000000
--- a/community/knowledge/performance/load-common-fields-before-branching-on-case.good.al
+++ /dev/null
@@ -1,25 +0,0 @@
-codeunit 50100 "Sales Document Processor"
-{
- procedure ProcessDocument(var SalesHeader: Record "Sales Header")
- begin
- // Tier 1: the discriminator and any fields every branch reads.
- SalesHeader.SetLoadFields("Document Type", "No.", "Sell-to Customer No.");
-
- case SalesHeader."Document Type" of
- SalesHeader."Document Type"::Order:
- begin
- // Tier 2: extend the load only on the branch that needs these fields.
- SalesHeader.SetLoadFields("Order Date", "Shipment Date", "Completely Shipped");
- ProcessOrder(SalesHeader);
- end;
- SalesHeader."Document Type"::Invoice:
- begin
- SalesHeader.SetLoadFields("Posting Date", "Amount Including VAT");
- ProcessInvoice(SalesHeader);
- end;
- end;
- end;
-
- local procedure ProcessOrder(var SalesHeader: Record "Sales Header") begin end;
- local procedure ProcessInvoice(var SalesHeader: Record "Sales Header") begin end;
-}
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
deleted file mode 100644
index f91d72e..0000000
--- a/community/knowledge/performance/load-common-fields-before-branching-on-case.md
+++ /dev/null
@@ -1,28 +0,0 @@
----
-bc-version: [all]
-domain: performance
-keywords: [setloadfields, case, conditional, branch, field-loading]
-technologies: [al]
-countries: [w1]
-application-area: [all]
----
-
-# Load common fields before branching on case
-
-> Contributions welcome β open a PR to refine or extend this article.
-
-## Description
-
-When record processing branches on state, different branches typically read different fields. A single `SetLoadFields` at the top listing every field any branch might touch pulls more data than any individual execution path needs β on the hot path, the rest is loaded for nothing. A two-tier approach matches loading to actual usage: load the fields the `case` expression evaluates plus any fields every branch uses, then add a branch-local `SetLoadFields` inside each branch for that branch's extra fields.
-
-## Best Practice
-
-Before the `case`, call `SetLoadFields` with the minimal set β the discriminator field and fields common to every branch. Inside each branch, before the first access to a branch-specific field, add a second `SetLoadFields` covering those fields. The platform honors the in-branch call for the next record operation, so the extra data is fetched only when the branch runs.
-
-See sample: `load-common-fields-before-branching-on-case.good.al`.
-
-## Anti Pattern
-
-A single top-level `SetLoadFields` enumerating every field any branch might read. On records whose state routes them to the fast common branch, the rarely-needed fields are still loaded β the optimization becomes a net-neutral or net-negative change on the hot path.
-
-See sample: `load-common-fields-before-branching-on-case.bad.al`.
diff --git a/community/knowledge/performance/omit-filter-only-fields-from-setloadfields.bad.al b/community/knowledge/performance/omit-filter-only-fields-from-setloadfields.bad.al
deleted file mode 100644
index 66e59af..0000000
--- a/community/knowledge/performance/omit-filter-only-fields-from-setloadfields.bad.al
+++ /dev/null
@@ -1,24 +0,0 @@
-codeunit 50100 "Recent Orders Summary"
-{
- procedure SummarizeRecentOrders(StartDate: Date; EndDate: Date)
- var
- SalesHeader: Record "Sales Header";
- begin
- // "Document Type" and "Document Date" are listed in SetLoadFields even
- // though they appear only in filters. Per-row values are transferred
- // for columns the processing body never reads.
- SalesHeader.SetLoadFields(
- "Document Type", "Document Date",
- "No.", "Sell-to Customer No.", "Amount Including VAT");
-
- SalesHeader.SetRange("Document Type", SalesHeader."Document Type"::Order);
- SalesHeader.SetRange("Document Date", StartDate, EndDate);
-
- if SalesHeader.FindSet() then
- repeat
- Emit(SalesHeader."No.", SalesHeader."Sell-to Customer No.", SalesHeader."Amount Including VAT");
- until SalesHeader.Next() = 0;
- end;
-
- local procedure Emit(No: Code[20]; CustNo: Code[20]; Amount: Decimal) begin end;
-}
diff --git a/community/knowledge/performance/omit-filter-only-fields-from-setloadfields.good.al b/community/knowledge/performance/omit-filter-only-fields-from-setloadfields.good.al
deleted file mode 100644
index 6e98764..0000000
--- a/community/knowledge/performance/omit-filter-only-fields-from-setloadfields.good.al
+++ /dev/null
@@ -1,22 +0,0 @@
-codeunit 50100 "Recent Orders Summary"
-{
- procedure SummarizeRecentOrders(StartDate: Date; EndDate: Date)
- var
- SalesHeader: Record "Sales Header";
- begin
- // "Document Type" and "Document Date" are used only in the filters below.
- // The database index handles them; there is no need to load their values
- // into AL memory for every row.
- SalesHeader.SetLoadFields("No.", "Sell-to Customer No.", "Amount Including VAT");
-
- SalesHeader.SetRange("Document Type", SalesHeader."Document Type"::Order);
- SalesHeader.SetRange("Document Date", StartDate, EndDate);
-
- if SalesHeader.FindSet() then
- repeat
- Emit(SalesHeader."No.", SalesHeader."Sell-to Customer No.", SalesHeader."Amount Including VAT");
- until SalesHeader.Next() = 0;
- end;
-
- local procedure Emit(No: Code[20]; CustNo: Code[20]; Amount: Decimal) begin end;
-}
diff --git a/community/knowledge/performance/omit-filter-only-fields-from-setloadfields.md b/community/knowledge/performance/omit-filter-only-fields-from-setloadfields.md
deleted file mode 100644
index c475f1b..0000000
--- a/community/knowledge/performance/omit-filter-only-fields-from-setloadfields.md
+++ /dev/null
@@ -1,28 +0,0 @@
----
-bc-version: [all]
-domain: performance
-keywords: [setloadfields, filter, field-exclusion, index]
-technologies: [al]
-countries: [w1]
-application-area: [all]
----
-
-# Omit filter-only fields from SetLoadFields
-
-> Contributions welcome β open a PR to refine or extend this article.
-
-## Description
-
-Fields used only in `SetRange` and `SetFilter` do their work at the database level using indexes; their values never need to be loaded into AL memory for the filter to apply. Listing such fields in `SetLoadFields` costs the transfer and memory footprint of every row's value for no functional benefit. Distinguishing filter-only fields from processing fields keeps the loaded column set as narrow as the iterating code actually reads.
-
-## Best Practice
-
-Include in `SetLoadFields` exactly the fields the iterating code reads. Fields referenced only in `SetRange`/`SetFilter` stay out of the list β filtering continues to work correctly because the database uses the index. Treat the audit as "what does the `repeatβ¦until` block touch?" rather than "what does this procedure mention?".
-
-See sample: `omit-filter-only-fields-from-setloadfields.good.al`.
-
-## Anti Pattern
-
-Listing every field the procedure mentions in `SetLoadFields`, including date-range or status fields that appear only in filters. The loaded record now carries per-row values for columns the processing body never reads, inflating memory and network cost without changing any behavior.
-
-See sample: `omit-filter-only-fields-from-setloadfields.bad.al`.
diff --git a/community/knowledge/performance/order-case-branches-by-frequency.md b/community/knowledge/performance/order-case-branches-by-frequency.md
deleted file mode 100644
index 5768004..0000000
--- a/community/knowledge/performance/order-case-branches-by-frequency.md
+++ /dev/null
@@ -1,28 +0,0 @@
----
-bc-version: [all]
-domain: performance
-keywords: [case, branch, frequency, control-flow, hot-path]
-technologies: [al]
-countries: [w1]
-application-area: [all]
----
-
-# Order case branches by frequency
-
-> Contributions welcome β open a PR to refine or extend this article.
-
-## Description
-
-The AL `case` statement evaluates branches in the order they appear. When the distribution of the discriminator is heavily skewed β one or two values handle the vast majority of records, and the rest handle edge cases β the average cost of the statement is dominated by how many branches precede the common one. For evenly distributed discriminators the order does not matter; for skewed distributions it changes the hot-path cost of every call site.
-
-## Best Practice
-
-Where the runtime frequency of values is known or measurable, list the common branches first. An `else` arm that handles unexpected values belongs last. When the common branch is also the simplest to evaluate, the placement compounds: the hot path is both short and cheap, and the uncommon branches are never touched on typical records.
-
-See sample: `order-case-branches-by-frequency.good.al`.
-
-## Anti Pattern
-
-Ordering branches alphabetically, by enum declaration order, or by "logical grouping" when the runtime distribution is heavily skewed. Every common record pays the cost of evaluating every uncommon branch first; on a posting routine processing thousands of rows the overhead is measurable.
-
-See sample: `order-case-branches-by-frequency.bad.al`.
diff --git a/community/knowledge/performance/use-deleteall-for-filtered-bulk-deletion.bad.al b/community/knowledge/performance/use-deleteall-for-filtered-bulk-deletion.bad.al
deleted file mode 100644
index 9e016d1..0000000
--- a/community/knowledge/performance/use-deleteall-for-filtered-bulk-deletion.bad.al
+++ /dev/null
@@ -1,19 +0,0 @@
-codeunit 50100 "Stale Quote Cleanup"
-{
- procedure ClearExpiredQuotes(CutoffDate: Date)
- var
- SalesHeader: Record "Sales Header";
- begin
- SalesHeader.SetRange("Document Type", SalesHeader."Document Type"::Quote);
- SalesHeader.SetFilter("Document Date", '<%1', CutoffDate);
- SalesHeader.SetRange(Status, SalesHeader.Status::Open);
-
- // One SQL DELETE per row. On a 10k-row cleanup, minutes instead of
- // under a second - and the OnDelete trigger has no logic this call
- // needs to run.
- if SalesHeader.FindSet() then
- repeat
- SalesHeader.Delete();
- until SalesHeader.Next() = 0;
- end;
-}
diff --git a/community/knowledge/performance/use-deleteall-for-filtered-bulk-deletion.good.al b/community/knowledge/performance/use-deleteall-for-filtered-bulk-deletion.good.al
deleted file mode 100644
index 697edd9..0000000
--- a/community/knowledge/performance/use-deleteall-for-filtered-bulk-deletion.good.al
+++ /dev/null
@@ -1,17 +0,0 @@
-codeunit 50100 "Stale Quote Cleanup"
-{
- procedure ClearExpiredQuotes(CutoffDate: Date)
- var
- SalesHeader: Record "Sales Header";
- begin
- // OnDelete on Sales Header carries no logic this call depends on:
- // expired quotes have no ledger entries, shipments, or downstream state.
- SalesHeader.SetRange("Document Type", SalesHeader."Document Type"::Quote);
- SalesHeader.SetFilter("Document Date", '<%1', CutoffDate);
- SalesHeader.SetRange(Status, SalesHeader.Status::Open);
-
- // Single SQL DELETE. Orders of magnitude faster than FindSet + Delete
- // once the filtered set exceeds a handful of rows.
- SalesHeader.DeleteAll();
- end;
-}
diff --git a/community/knowledge/performance/use-deleteall-for-filtered-bulk-deletion.md b/community/knowledge/performance/use-deleteall-for-filtered-bulk-deletion.md
deleted file mode 100644
index 0c5a1de..0000000
--- a/community/knowledge/performance/use-deleteall-for-filtered-bulk-deletion.md
+++ /dev/null
@@ -1,28 +0,0 @@
----
-bc-version: [all]
-domain: performance
-keywords: [deleteall, bulk-delete, sql, ondelete, trigger-bypass]
-technologies: [al]
-countries: [w1]
-application-area: [all]
----
-
-# Use DeleteAll for filtered bulk deletion
-
-> Contributions welcome β open a PR to refine or extend this article.
-
-## Description
-
-`DeleteAll` translates to a single SQL `DELETE` with the record variable's current filters applied as the WHERE clause. A loop of `FindSet` + `Delete` instead issues one SQL statement per row. On any dataset larger than a handful of records, the gap is an order of magnitude or more. The tradeoff is that `DeleteAll` bypasses the `OnDelete` table trigger, so the decision hinges on whether that trigger's logic is required for this specific deletion.
-
-## Best Practice
-
-After narrowing the record set with `SetRange`/`SetFilter`, use `DeleteAll` whenever the `OnDelete` trigger has no logic that this call depends on β typically the case for housekeeping routines, staging-table cleanup, and deletions already validated upstream. When the trigger IS required, either keep the explicit loop-plus-`Delete` pattern and comment why, or pre-run the trigger logic against a temporary buffer and then `DeleteAll` the primary table.
-
-See sample: `use-deleteall-for-filtered-bulk-deletion.good.al`.
-
-## Anti Pattern
-
-Iterating with `FindSet` + `Delete` to clear a filtered set of records that carry no meaningful `OnDelete` logic. Every row pays a full AL round-trip; on a ten-thousand-row cleanup the loop can take minutes where `DeleteAll` takes under a second.
-
-See sample: `use-deleteall-for-filtered-bulk-deletion.bad.al`.
diff --git a/community/knowledge/ui/factbox-design.md b/community/knowledge/ui/factbox-design.md
new file mode 100644
index 0000000..b4f0544
--- /dev/null
+++ b/community/knowledge/ui/factbox-design.md
@@ -0,0 +1,20 @@
+---
+bc-version: [all]
+domain: ui
+keywords: [factbox, subpagelink, listpart, cardpart, page-part, related-information, flowfield-sift]
+technologies: [al]
+countries: [w1]
+application-area: [all]
+---
+# Filter ListPart FactBoxes With SubPageLink To The Parent Record
+
+> Contributions welcome β open a PR to refine or extend this article.
+
+## Description
+A FactBox is a page `part` that surfaces related data beside the main record so users avoid navigating away. Every FactBox runs a database query as its host page loads, so an unfiltered one is a hidden performance tax paid on every page open. The remedial trap: a `ListPart` FactBox with no `SubPageLink` does not show "the related rows" β it loads and pages through the entire source table, because nothing ties it to the host record. This makes correct `SubPageLink` linkage, not visual layout, the load-bearing design decision.
+
+## Best Practice
+Give every `ListPart` FactBox a `SubPageLink` that maps a field on the part's source table to a `field()` of the host record (for example `SubPageLink = "Document No." = field("No.")`), so it returns only rows belonging to the current record. Prefer a `CardPart` when you only need summary figures (balance, availability, status) β it reads a single record and avoids list overhead entirely. When a FactBox shows FlowFields, ensure the calculated total is backed by a SIFT key (`MaintainSIFTIndex`) so the sum is read from the index rather than aggregated row-by-row on each load. Keep FactBox count modest and avoid heavy `OnAfterGetRecord` logic in the part.
+
+## Anti Pattern
+Adding a `ListPart` FactBox without a `SubPageLink`, expecting it to "just show related lines." The consequence is a full-table scan on every page load that grows with the dataset and is felt worst on list pages, where the FactBox re-queries on each row selection. Reviewer signal: any `part(...)` referencing a list-type page part where the `SubPageLink` property is absent, or a FactBox FlowField filtered on non-indexed fields. A second smell is duplicating data already on the page or stacking many FactBoxes, which multiplies queries for little context gain.
diff --git a/microsoft/knowledge/appsource/keep-copilot-help-url-to-two-path-levels.md b/microsoft/knowledge/appsource/keep-copilot-help-url-to-two-path-levels.md
new file mode 100644
index 0000000..9ceaa3b
--- /dev/null
+++ b/microsoft/knowledge/appsource/keep-copilot-help-url-to-two-path-levels.md
@@ -0,0 +1,22 @@
+---
+bc-version: [27..]
+domain: appsource
+keywords: [app-json, help-url, copilot, grounding, documentation, url-depth, contexturl]
+technologies: [al]
+countries: [w1]
+application-area: [all]
+---
+
+# Keep the Copilot help URL to two path levels
+
+## Description
+
+The `help` URL declared in `app.json` is what Copilot uses to ground answers about your app. That URL may be at most **two path levels** deep (for example `https://contoso.com/docs/myapp`). If you point it at a deeper path (three or more segments), Copilot does not use the URL as given: it truncates to the first two levels, drops any fragments and query strings, and then grounds on **all** content beneath that two-level path. The failure is silent β there is no build error β and the practical effect is worse answers, because Copilot may ingest sibling apps' documentation that lives under the same two-level parent.
+
+## Best Practice
+
+Organize per-app documentation so the canonical help page sits no deeper than two path levels, and confirm during testing that Copilot citations resolve to your app's content rather than a broader parent. If your docs naturally nest deeper, give each app a dedicated two-level path it owns.
+
+## Anti Pattern
+
+Setting `help` to a deep, tidy-looking docs path such as `https://contoso.com/docs/products/erp/myapp/setup`. Copilot truncates it to `β¦/docs/products`, then grounds on everything under that node β pulling in unrelated content and degrading answer quality for your users.
diff --git a/microsoft/knowledge/appsource/object-affixes-prevent-collisions.bad.al b/microsoft/knowledge/appsource/object-affixes-prevent-collisions.bad.al
new file mode 100644
index 0000000..dc1c6f3
--- /dev/null
+++ b/microsoft/knowledge/appsource/object-affixes-prevent-collisions.bad.al
@@ -0,0 +1,43 @@
+// Anti-pattern: an own object with no affix. Another app that also defines a
+// "Loyalty Tier" table cannot be installed alongside this one.
+table 50379 "Loyalty Tier"
+{
+ Caption = 'Loyalty Tier';
+ DataClassification = CustomerContent;
+
+ fields
+ {
+ field(1; "Code"; Code[20])
+ {
+ Caption = 'Code';
+ }
+ field(10; Description; Text[100])
+ {
+ Caption = 'Description';
+ }
+ }
+
+ keys
+ {
+ key(PK; "Code")
+ {
+ Clustered = true;
+ }
+ }
+}
+
+// Anti-pattern (the common half-measure): the extension object carries the
+// affix, but the field it adds to the standard Customer table does not. That
+// unaffixed field still collides with any other app that adds "Loyalty Points"
+// to Customer, and AS0011 flags it.
+tableextension 50378 "ABC Customer Ext" extends Customer
+{
+ fields
+ {
+ field(50378; "Loyalty Points"; Integer)
+ {
+ Caption = 'Loyalty Points';
+ DataClassification = CustomerContent;
+ }
+ }
+}
diff --git a/microsoft/knowledge/appsource/object-affixes-prevent-collisions.good.al b/microsoft/knowledge/appsource/object-affixes-prevent-collisions.good.al
new file mode 100644
index 0000000..28bfa4d
--- /dev/null
+++ b/microsoft/knowledge/appsource/object-affixes-prevent-collisions.good.al
@@ -0,0 +1,40 @@
+// Own object: the affix "ABC" is carried at object-name level.
+table 50377 "ABC Loyalty Tier"
+{
+ Caption = 'Loyalty Tier';
+ DataClassification = CustomerContent;
+
+ fields
+ {
+ field(1; "Code"; Code[20])
+ {
+ Caption = 'Code';
+ }
+ field(10; Description; Text[100])
+ {
+ Caption = 'Description';
+ }
+ }
+
+ keys
+ {
+ key(PK; "Code")
+ {
+ Clustered = true;
+ }
+ }
+}
+
+// Extension of a standard object: the added field is individually affixed,
+// because the object name (Customer) belongs to the base application.
+tableextension 50376 "ABC Customer Ext" extends Customer
+{
+ fields
+ {
+ field(50376; "Loyalty Points ABC"; Integer)
+ {
+ Caption = 'Loyalty Points';
+ DataClassification = CustomerContent;
+ }
+ }
+}
diff --git a/microsoft/knowledge/appsource/object-affixes-prevent-collisions.md b/microsoft/knowledge/appsource/object-affixes-prevent-collisions.md
new file mode 100644
index 0000000..49ef80c
--- /dev/null
+++ b/microsoft/knowledge/appsource/object-affixes-prevent-collisions.md
@@ -0,0 +1,28 @@
+---
+bc-version: [all]
+domain: appsource
+keywords: [object-affix, prefix, suffix, as0011, appsourcecop, collision, tableextension]
+technologies: [al]
+countries: [w1]
+application-area: [all]
+---
+
+# Apply a reserved affix to objects and to members added to base objects
+
+## Description
+
+An AppSource extension must carry a reserved affix β a prefix or a suffix of at least three characters β on the names of the objects it owns **and** on any field, key, control, or action it adds to a base-application object. The affix is registered with Microsoft; when two coexisting extensions would otherwise collide, the registrant of the affix wins. Without it, two apps that both add a `Loyalty Points` field to `Customer`, or both define a `Loyalty Tier` table, cannot be installed side by side.
+
+AppSourceCop enforces this. The primary rule is AS0011 ("An affix is required"); the affixes are configured through `mandatoryAffixes` (and `mandatoryPrefix`) in `AppSourceCop.json`. Two placements matter and are easy to get half-right: an object you define carries the affix at **object-name** level, while a member you add to a **standard** object carries the affix on that **member's** name. Adding an affixed object is not enough β an unaffixed field bolted onto `Customer` still collides and still fails validation.
+
+## Best Practice
+
+Own objects are named with the affix (e.g. a table `ABC Loyalty Tier`), and every field or action added to a standard object is individually affixed (e.g. `Loyalty Points ABC` on a `Customer` tableextension).
+
+See sample: `object-affixes-prevent-collisions.good.al`.
+
+## Anti Pattern
+
+Unaffixed object or member names, or the common half-measure: the extension object carries the affix but a field it adds to a standard table does not. AS0011 flags the missing affix and the field can still collide with another app.
+
+See sample: `object-affixes-prevent-collisions.bad.al`.
diff --git a/microsoft/knowledge/breaking-changes/deprecate-public-members-with-the-obsolete-lifecycle.good.al b/microsoft/knowledge/breaking-changes/deprecate-public-members-with-the-obsolete-lifecycle.good.al
index 4f2638a..6d6e960 100644
--- a/microsoft/knowledge/breaking-changes/deprecate-public-members-with-the-obsolete-lifecycle.good.al
+++ b/microsoft/knowledge/breaking-changes/deprecate-public-members-with-the-obsolete-lifecycle.good.al
@@ -1,7 +1,7 @@
codeunit 50305 "Net Amount Api Good"
{
- // Old name kept and marked obsolete: callers still compile but get a warning
- // pointing at the replacement, with a tag recording the removal target version.
+ // Old name kept during the warning window. The tag records when obsoletion
+ // began; a later release deletes the method after consumers have migrated.
[Obsolete('Use CalculateNetAmount instead.', '25.0')]
procedure CalcNet(GrossAmount: Decimal; TaxRate: Decimal): Decimal
begin
diff --git a/microsoft/knowledge/breaking-changes/deprecate-public-members-with-the-obsolete-lifecycle.md b/microsoft/knowledge/breaking-changes/deprecate-public-members-with-the-obsolete-lifecycle.md
index 5a699b6..35a342e 100644
--- a/microsoft/knowledge/breaking-changes/deprecate-public-members-with-the-obsolete-lifecycle.md
+++ b/microsoft/knowledge/breaking-changes/deprecate-public-members-with-the-obsolete-lifecycle.md
@@ -11,16 +11,16 @@ application-area: [all]
## Description
-Deleting or renaming a published procedure (or object) in a single release is a hard break: dependent extensions that reference it stop compiling the moment they pick up the new version, with no warning window to migrate. AL provides a staged deprecation lifecycle precisely so consumers get advance notice. For a procedure, apply the `[Obsolete('reason', 'tag')]` attribute: the member keeps working but every caller gets a compiler warning naming the replacement and the target version. The member stays through a deprecation window β at least one major release β before it is finally removed. Object- and field-level members use the matching `ObsoleteState = Pending` β `Removed` property progression. LLMs trained to "clean up" code often delete or rename the old member immediately, skipping the window entirely.
+Deleting or renaming a published procedure (or object) in a single release is a hard break: dependent extensions that reference it stop compiling the moment they pick up the new version, with no warning window to migrate. AL provides staged deprecation so consumers get advance notice. A procedure uses `[Obsolete('reason', 'tag')]`: it remains callable but callers receive a compiler warning naming the replacement and the version in which obsoletion began. Methods do not have `ObsoleteState`; after the deprecation window, the method is deleted, commonly through versioned preprocessor cleanup. Objects and fields instead use the `ObsoleteState = Pending` to `Removed` property progression.
## Best Practice
-When a published procedure is superseded, keep it in place and mark it `[Obsolete('Use CalculateNetAmount instead.', '25.0')]`, where the message names the replacement and the tag records the target version for removal. Have the obsolete member forward to the new one so behavior is preserved during the window. Only after the deprecation window has elapsed β a later release β change its state to removed. This gives every dependent app a compile-time signal and time to migrate before anything actually disappears.
+When a published procedure is superseded, keep it in place and mark it `[Obsolete('Use CalculateNetAmount instead.', '25.0')]`, where the message names the replacement and the tag records when the method became obsolete. Have the obsolete member forward to the new one so behavior is preserved during the window. Only after the deprecation window has elapsed should a later release delete the method. For an object or field, use `Pending` during the warning window and `Removed` afterward.
See sample: `deprecate-public-members-with-the-obsolete-lifecycle.good.al`.
## Anti Pattern
-Renaming or deleting the published `CalcNet` procedure in place β replacing it with `CalculateNetAmount` and nothing else β so consumers calling `CalcNet` break immediately with no deprecation notice. Detection: a previously shipped non-`local` procedure that vanished or was renamed between versions with no `[Obsolete]` marker left behind on a kept member. Mark it obsolete and keep it for a window instead.
+Renaming or deleting the published `CalcNet` procedure in place β replacing it with `CalculateNetAmount` and nothing else β so consumers calling `CalcNet` break immediately with no deprecation notice. Detection: a previously shipped non-`local` procedure that vanished or was renamed between versions with no `[Obsolete]` marker left behind during a prior warning window. Do not suggest `ObsoleteState = Removed` for a method; that property belongs to supported object and element types.
See sample: `deprecate-public-members-with-the-obsolete-lifecycle.bad.al`.
diff --git a/microsoft/knowledge/breaking-changes/do-not-expose-sensitive-data-through-public-api.good.al b/microsoft/knowledge/breaking-changes/do-not-expose-sensitive-data-through-public-api.good.al
index 4073930..271c7f4 100644
--- a/microsoft/knowledge/breaking-changes/do-not-expose-sensitive-data-through-public-api.good.al
+++ b/microsoft/knowledge/breaking-changes/do-not-expose-sensitive-data-through-public-api.good.al
@@ -1,10 +1,10 @@
codeunit 50320 "Payment Client Good"
{
var
- AccessToken: Text;
+ AccessToken: SecretText;
- // Credential flows inward through an internal setter and never leaves the object.
- internal procedure SetAccessToken(NewToken: Text)
+ // Credential remains SecretText as it flows inward and is stored.
+ internal procedure SetAccessToken(NewToken: SecretText)
begin
AccessToken := NewToken;
end;
diff --git a/microsoft/knowledge/breaking-changes/do-not-expose-sensitive-data-through-public-api.md b/microsoft/knowledge/breaking-changes/do-not-expose-sensitive-data-through-public-api.md
index 65c7971..bd32d2d 100644
--- a/microsoft/knowledge/breaking-changes/do-not-expose-sensitive-data-through-public-api.md
+++ b/microsoft/knowledge/breaking-changes/do-not-expose-sensitive-data-through-public-api.md
@@ -1,5 +1,5 @@
---
-bc-version: [all]
+bc-version: [23..]
domain: breaking-changes
keywords: [sensitive-data, secrettext, token, credential, public-api, access-boundary]
technologies: [al]
diff --git a/microsoft/knowledge/breaking-changes/obsolete-table-fields-instead-of-deleting-them.bad.al b/microsoft/knowledge/breaking-changes/obsolete-table-fields-instead-of-deleting-them.bad.al
index 0e2f000..1277fc2 100644
--- a/microsoft/knowledge/breaking-changes/obsolete-table-fields-instead-of-deleting-them.bad.al
+++ b/microsoft/knowledge/breaking-changes/obsolete-table-fields-instead-of-deleting-them.bad.al
@@ -3,9 +3,9 @@ table 50311 "Customer Profile Bad"
fields
{
field(1; "No."; Code[20]) { }
- // Breaking: the published "Email" field was renamed in place. Dependent
- // extensions that reference "Email" stop compiling, and the data stored in
- // the old column is orphaned on upgrade.
+ // Breaking: the published field was renamed while retaining ID 2.
+ // AppSourceCop AS0005 rejects the compatibility change; retaining the ID
+ // does not by itself mean the stored column was dropped and re-created.
field(2; "Contact Email"; Text[80]) { }
}
}
diff --git a/microsoft/knowledge/breaking-changes/obsolete-table-fields-instead-of-deleting-them.md b/microsoft/knowledge/breaking-changes/obsolete-table-fields-instead-of-deleting-them.md
index e1e7116..0d5a289 100644
--- a/microsoft/knowledge/breaking-changes/obsolete-table-fields-instead-of-deleting-them.md
+++ b/microsoft/knowledge/breaking-changes/obsolete-table-fields-instead-of-deleting-them.md
@@ -7,20 +7,20 @@ countries: [w1]
application-area: [all]
---
-# Obsolete published table fields instead of deleting or renaming them
+# Obsolete published table fields instead of deleting or renumbering them
## Description
-A table field that has shipped carries two contracts at once: extensions reference it by name, and the database holds data in its column. Deleting the field, or renaming it (which the platform treats as drop-plus-add), breaks dependent code at compile time and discards the stored data β a silent data-loss event on upgrade. The fix is the same staged lifecycle used for objects: set `ObsoleteState = Pending` together with `ObsoleteReason` and an `ObsoleteTag` naming the target version, ship the new field alongside, migrate data during the window, and only switch the old field to `ObsoleteState = Removed` in a later release once nothing depends on it. LLMs often "tidy" a schema by renaming a field in place, not realizing this is both a breaking change and a data-loss risk.
+A shipped table field carries both a source-level contract and persisted data. Renaming a field while retaining its ID is prohibited by AppSourceCop AS0005 and can break dependent extensions, but it is not inherently a drop-and-readd operation and should not be described as automatic data loss. Deleting the field or replacing it under a different ID is the data-loss risk: the old field storage is no longer represented unless data is migrated. The supported path is to keep the old field and obsolete it, add a replacement under a new ID, and migrate values before later removal.
## Best Practice
-Add the replacement field, then mark the old field `ObsoleteState = Pending` with an `ObsoleteReason` that names the replacement and an `ObsoleteTag` carrying the target version (for example `'25.0'`). Keep the obsolete field readable so an upgrade codeunit can copy its data into the new field during the deprecation window. Move it to `ObsoleteState = Removed` only in a later major version, after the window has passed and data has migrated.
+Add the replacement field under a new ID, then mark the old field `ObsoleteState = Pending` with an `ObsoleteReason` that names the replacement and an `ObsoleteTag` recording the obsoletion version. Keep the old field readable so an upgrade codeunit can copy its data during the deprecation window. Move it to `ObsoleteState = Removed` only in a later release, after the window has passed and data has migrated.
See sample: `obsolete-table-fields-instead-of-deleting-them.good.al`.
## Anti Pattern
-Renaming the published `Email` field to `Contact Email` directly in the table β or deleting it β so dependent extensions that reference `Email` break and the column's stored values are orphaned on upgrade. Detection: a previously shipped field removed or renamed in a table or table extension with no `ObsoleteState = Pending` step preserving the original. Obsolete the field through the lifecycle instead.
+Renaming published `Email` to `Contact Email` with the same ID violates the compatibility contract and AS0005, even though the retained ID does not itself imply a fresh empty column. Deleting `Email` or moving the replacement to another ID without migration additionally risks losing its stored values. Detection: a previously shipped field removed, renumbered, or renamed with no retained `Pending` field and migration path.
See sample: `obsolete-table-fields-instead-of-deleting-them.bad.al`.
diff --git a/microsoft/knowledge/data-modeling/check-blocked-in-referencing-code-not-in-master.bad.al b/microsoft/knowledge/data-modeling/check-blocked-in-referencing-code-not-in-master.bad.al
new file mode 100644
index 0000000..7cf9d52
--- /dev/null
+++ b/microsoft/knowledge/data-modeling/check-blocked-in-referencing-code-not-in-master.bad.al
@@ -0,0 +1,71 @@
+table 50372 "Loyalty Member"
+{
+ Caption = 'Loyalty Member';
+ DataClassification = CustomerContent;
+
+ fields
+ {
+ field(1; "No."; Code[20])
+ {
+ Caption = 'No.';
+ }
+ field(10; Name; Text[100])
+ {
+ Caption = 'Name';
+ }
+ field(20; Blocked; Boolean)
+ {
+ Caption = 'Blocked';
+ }
+ }
+
+ keys
+ {
+ key(PK; "No.")
+ {
+ Clustered = true;
+ }
+ }
+
+ // Anti-pattern: the block check sits in the master's own trigger. Editing a
+ // blocked member is rare; referencing it is constant, and references never
+ // fire OnModify. So this stops nothing that matters.
+ trigger OnModify()
+ begin
+ TestField(Blocked, false);
+ end;
+}
+
+table 50373 "Loyalty Point Entry"
+{
+ Caption = 'Loyalty Point Entry';
+ DataClassification = CustomerContent;
+
+ fields
+ {
+ field(1; "Entry No."; Integer)
+ {
+ Caption = 'Entry No.';
+ AutoIncrement = true;
+ }
+ field(10; "Member No."; Code[20])
+ {
+ Caption = 'Member No.';
+ TableRelation = "Loyalty Member"."No.";
+ // No block check on the referencing side: a line can freely
+ // reference a blocked member, and posting proceeds unchecked.
+ }
+ field(20; Points; Integer)
+ {
+ Caption = 'Points';
+ }
+ }
+
+ keys
+ {
+ key(PK; "Entry No.")
+ {
+ Clustered = true;
+ }
+ }
+}
diff --git a/microsoft/knowledge/data-modeling/check-blocked-in-referencing-code-not-in-master.good.al b/microsoft/knowledge/data-modeling/check-blocked-in-referencing-code-not-in-master.good.al
new file mode 100644
index 0000000..f76a46a
--- /dev/null
+++ b/microsoft/knowledge/data-modeling/check-blocked-in-referencing-code-not-in-master.good.al
@@ -0,0 +1,84 @@
+table 50370 "Loyalty Member"
+{
+ Caption = 'Loyalty Member';
+ DataClassification = CustomerContent;
+
+ fields
+ {
+ field(1; "No."; Code[20])
+ {
+ Caption = 'No.';
+ }
+ field(10; Name; Text[100])
+ {
+ Caption = 'Name';
+ }
+ // Blocked is inert data here: the master carries the flag but no logic.
+ field(20; Blocked; Boolean)
+ {
+ Caption = 'Blocked';
+ }
+ }
+
+ keys
+ {
+ key(PK; "No.")
+ {
+ Clustered = true;
+ }
+ }
+}
+
+table 50371 "Loyalty Point Entry"
+{
+ Caption = 'Loyalty Point Entry';
+ DataClassification = CustomerContent;
+
+ fields
+ {
+ field(1; "Entry No."; Integer)
+ {
+ Caption = 'Entry No.';
+ AutoIncrement = true;
+ }
+ field(10; "Member No."; Code[20])
+ {
+ Caption = 'Member No.';
+ TableRelation = "Loyalty Member"."No.";
+
+ trigger OnValidate()
+ var
+ LoyaltyMember: Record "Loyalty Member";
+ begin
+ if "Member No." = '' then
+ exit;
+ // Enforcement lives at the point of use: reject a blocked master
+ // as soon as a line references it.
+ LoyaltyMember.Get("Member No.");
+ LoyaltyMember.TestField(Blocked, false);
+ end;
+ }
+ field(20; Points; Integer)
+ {
+ Caption = 'Points';
+ }
+ }
+
+ keys
+ {
+ key(PK; "Entry No.")
+ {
+ Clustered = true;
+ }
+ }
+
+ procedure Post()
+ var
+ LoyaltyMember: Record "Loyalty Member";
+ begin
+ // Re-check before committing the transaction, in case the member was
+ // blocked after the line was created.
+ LoyaltyMember.Get("Member No.");
+ LoyaltyMember.TestField(Blocked, false);
+ end;
+}
diff --git a/microsoft/knowledge/data-modeling/check-blocked-in-referencing-code-not-in-master.md b/microsoft/knowledge/data-modeling/check-blocked-in-referencing-code-not-in-master.md
new file mode 100644
index 0000000..e324d45
--- /dev/null
+++ b/microsoft/knowledge/data-modeling/check-blocked-in-referencing-code-not-in-master.md
@@ -0,0 +1,28 @@
+---
+bc-version: [all]
+domain: data-modeling
+keywords: [blocked-field, testfield, referencing-code, point-of-use, enforcement, journal-line]
+technologies: [al]
+countries: [w1]
+application-area: [all]
+---
+
+# Enforce `Blocked` where the master is used, not in the master itself
+
+## Description
+
+The `Blocked` field on a master record (`Item`, `Customer`, `Resource`, or a custom master) is inert data. The master table holds **no** logic that acts on it. Enforcement belongs in the **consuming** code: when a journal line, document line, or posting routine references the master by its `No.`, that referencing object tests the flag at the point of use, e.g. `LoyaltyMember.Get("Member No."); LoyaltyMember.TestField(Blocked, false);` in the line's `OnValidate` and again before posting.
+
+Putting the block check inside the master's own `OnInsert`/`OnModify` does nothing to stop transactional use: a blocked master is edited rarely, but it is *referenced* constantly, and those references never touch the master's own triggers. Base BC follows this split β `Item.Blocked` is checked by sales/purchase/journal code, not by the `Item` table. A boolean `Blocked` uses `TestField(Blocked, false)`; an option-style block (e.g. `Sales`/`All`) needs the specific option compared at each relevant path.
+
+## Best Practice
+
+The referencing line validates `Master.TestField(Blocked, false)` in `OnValidate` of the reference field and re-checks before posting. The master table stays logic-free on `Blocked`.
+
+See sample: `check-blocked-in-referencing-code-not-in-master.good.al`.
+
+## Anti Pattern
+
+The block check sits in the master's own `OnModify`/`OnInsert` (so referencing and posting proceed unchecked), or there is no check at all on the referencing side.
+
+See sample: `check-blocked-in-referencing-code-not-in-master.bad.al`.
diff --git a/microsoft/knowledge/data-modeling/master-table-no-from-number-series-in-oninsert.bad.al b/microsoft/knowledge/data-modeling/master-table-no-from-number-series-in-oninsert.bad.al
new file mode 100644
index 0000000..307b925
--- /dev/null
+++ b/microsoft/knowledge/data-modeling/master-table-no-from-number-series-in-oninsert.bad.al
@@ -0,0 +1,31 @@
+table 50361 "Loyalty Member"
+{
+ Caption = 'Loyalty Member';
+ DataClassification = CustomerContent;
+
+ fields
+ {
+ // Anti-pattern: an autoincrement Integer surrogate used as the business key.
+ field(1; "Entry No."; Integer)
+ {
+ Caption = 'Entry No.';
+ AutoIncrement = true;
+ }
+ field(10; Name; Text[100])
+ {
+ Caption = 'Name';
+ }
+ }
+
+ keys
+ {
+ key(PK; "Entry No.")
+ {
+ Clustered = true;
+ }
+ }
+
+ // No OnInsert, no number series, no "No." code, and no "No. Series" field.
+ // Records get an opaque integer users never see and cannot quote on the phone,
+ // and the master is cut off from BC's standard numbering and manual-entry flow.
+}
diff --git a/microsoft/knowledge/data-modeling/master-table-no-from-number-series-in-oninsert.good.al b/microsoft/knowledge/data-modeling/master-table-no-from-number-series-in-oninsert.good.al
new file mode 100644
index 0000000..5bc49d3
--- /dev/null
+++ b/microsoft/knowledge/data-modeling/master-table-no-from-number-series-in-oninsert.good.al
@@ -0,0 +1,45 @@
+table 50360 "Loyalty Member"
+{
+ Caption = 'Loyalty Member';
+ DataClassification = CustomerContent;
+
+ fields
+ {
+ field(1; "No."; Code[20])
+ {
+ Caption = 'No.';
+ NotBlank = true;
+ }
+ field(2; "No. Series"; Code[20])
+ {
+ Caption = 'No. Series';
+ Editable = false;
+ TableRelation = "No. Series";
+ }
+ field(10; Name; Text[100])
+ {
+ Caption = 'Name';
+ }
+ }
+
+ keys
+ {
+ key(PK; "No.")
+ {
+ Clustered = true;
+ }
+ }
+
+ trigger OnInsert()
+ var
+ LoyaltySetup: Record "Loyalty Setup";
+ NoSeries: Codeunit "No. Series";
+ begin
+ if "No." = '' then begin
+ LoyaltySetup.Get();
+ LoyaltySetup.TestField("Member Nos.");
+ "No. Series" := LoyaltySetup."Member Nos.";
+ "No." := NoSeries.GetNextNo("No. Series");
+ end;
+ end;
+}
diff --git a/microsoft/knowledge/data-modeling/master-table-no-from-number-series-in-oninsert.md b/microsoft/knowledge/data-modeling/master-table-no-from-number-series-in-oninsert.md
new file mode 100644
index 0000000..f4c6a15
--- /dev/null
+++ b/microsoft/knowledge/data-modeling/master-table-no-from-number-series-in-oninsert.md
@@ -0,0 +1,28 @@
+---
+bc-version: [all]
+domain: data-modeling
+keywords: [no-series, primary-key, code20, oninsert, autoincrement, number-assignment]
+technologies: [al]
+countries: [w1]
+application-area: [all]
+---
+
+# A master table's `No.` primary key comes from a number series in `OnInsert`
+
+## Description
+
+In Business Central, a master table (Customer, Vendor, Item, and any custom equivalent) uses a single primary-key field named `No.` of type `Code[20]`. It is populated from a number series β configured on the feature's application-area setup table β inside the table's `OnInsert` trigger, but only when `No.` is still blank (so a user may still type a manual number when the series allows it). The record also keeps a non-editable `No. Series` `Code[20]` field recording which series produced the number.
+
+This is not an `Integer` `AutoIncrement` key, a GUID, or the `SystemId`. Those are surrogate/system identifiers that users never see and cannot quote; BC's whole document flow β lookups, filtering, printed references, telephone support β depends on a short, human-readable, business-controlled `No.`. Use the modern assignment API described in `use-no-series-codeunit-not-noseriesmanagement.md`.
+
+## Best Practice
+
+`No.` `Code[20]` is the sole primary key; a non-editable `No. Series` `Code[20]` field records the source series. `OnInsert` checks `if "No." = ''`, reads the setup table, `TestField`s the configured series, stores it in `No. Series`, and assigns `No.` from the series.
+
+See sample: `master-table-no-from-number-series-in-oninsert.good.al`.
+
+## Anti Pattern
+
+An `Integer` `AutoIncrement` (or GUID / `SystemId`) primary key used as the business key, with no `OnInsert` number assignment. Records get an opaque identifier no user can reference, and the master no longer participates in the standard numbering and manual-entry behavior every other BC master follows.
+
+See sample: `master-table-no-from-number-series-in-oninsert.bad.al`.
diff --git a/microsoft/knowledge/data-modeling/set-last-date-modified-in-onmodify-and-onrename.bad.al b/microsoft/knowledge/data-modeling/set-last-date-modified-in-onmodify-and-onrename.bad.al
new file mode 100644
index 0000000..c2031a0
--- /dev/null
+++ b/microsoft/knowledge/data-modeling/set-last-date-modified-in-onmodify-and-onrename.bad.al
@@ -0,0 +1,39 @@
+table 50369 "Loyalty Member"
+{
+ Caption = 'Loyalty Member';
+ DataClassification = CustomerContent;
+
+ fields
+ {
+ field(1; "No."; Code[20])
+ {
+ Caption = 'No.';
+ }
+ field(10; Name; Text[100])
+ {
+ Caption = 'Name';
+ }
+ field(20; "Last Date Modified"; Date)
+ {
+ Caption = 'Last Date Modified';
+ Editable = false;
+ }
+ }
+
+ keys
+ {
+ key(PK; "No.")
+ {
+ Clustered = true;
+ }
+ }
+
+ trigger OnModify()
+ begin
+ "Last Date Modified" := Today();
+ end;
+
+ // Missing OnRename: renaming the member changes the primary key without
+ // firing OnModify, so "Last Date Modified" keeps its old, stale value and
+ // change-detection logic downstream skips the renamed record.
+}
diff --git a/microsoft/knowledge/data-modeling/set-last-date-modified-in-onmodify-and-onrename.good.al b/microsoft/knowledge/data-modeling/set-last-date-modified-in-onmodify-and-onrename.good.al
new file mode 100644
index 0000000..18e1434
--- /dev/null
+++ b/microsoft/knowledge/data-modeling/set-last-date-modified-in-onmodify-and-onrename.good.al
@@ -0,0 +1,40 @@
+table 50368 "Loyalty Member"
+{
+ Caption = 'Loyalty Member';
+ DataClassification = CustomerContent;
+
+ fields
+ {
+ field(1; "No."; Code[20])
+ {
+ Caption = 'No.';
+ }
+ field(10; Name; Text[100])
+ {
+ Caption = 'Name';
+ }
+ field(20; "Last Date Modified"; Date)
+ {
+ Caption = 'Last Date Modified';
+ Editable = false;
+ }
+ }
+
+ keys
+ {
+ key(PK; "No.")
+ {
+ Clustered = true;
+ }
+ }
+
+ trigger OnModify()
+ begin
+ "Last Date Modified" := Today();
+ end;
+
+ trigger OnRename()
+ begin
+ "Last Date Modified" := Today();
+ end;
+}
diff --git a/microsoft/knowledge/data-modeling/set-last-date-modified-in-onmodify-and-onrename.md b/microsoft/knowledge/data-modeling/set-last-date-modified-in-onmodify-and-onrename.md
new file mode 100644
index 0000000..dbc0a64
--- /dev/null
+++ b/microsoft/knowledge/data-modeling/set-last-date-modified-in-onmodify-and-onrename.md
@@ -0,0 +1,28 @@
+---
+bc-version: [all]
+domain: data-modeling
+keywords: [last-date-modified, onmodify, onrename, audit-field, non-editable, stale-value]
+technologies: [al]
+countries: [w1]
+application-area: [all]
+---
+
+# Refresh `Last Date Modified` in both `OnModify` and `OnRename`
+
+## Description
+
+Master tables carry a non-editable `Last Date Modified` field of type `Date`. It records when the record last changed and is refreshed by table triggers, not by the user. The refresh must happen in **both** `OnModify` and `OnRename`.
+
+The reason is a BC-specific trap: renaming a record changes its primary key and fires `OnRename` β it does **not** fire `OnModify`. A table that updates `Last Date Modified` only in `OnModify` therefore leaves a stale date behind every rename. Downstream logic that keys on this field (incremental sync, integration deltas, "changed since" reports) then silently skips the renamed record. Assign `Today` (the system date), not `WorkDate`, because the field reflects the real modification moment.
+
+## Best Practice
+
+Both `OnModify` and `OnRename` set `"Last Date Modified" := Today();`, and the field is declared `Editable = false` so only the triggers maintain it.
+
+See sample: `set-last-date-modified-in-onmodify-and-onrename.good.al`.
+
+## Anti Pattern
+
+Only `OnModify` assigns `Last Date Modified`. After a rename the value is stale, and any process that trusts it to detect changes misses the record.
+
+See sample: `set-last-date-modified-in-onmodify-and-onrename.bad.al`.
diff --git a/microsoft/knowledge/data-modeling/setup-table-is-a-singleton.bad.al b/microsoft/knowledge/data-modeling/setup-table-is-a-singleton.bad.al
new file mode 100644
index 0000000..c6585c8
--- /dev/null
+++ b/microsoft/knowledge/data-modeling/setup-table-is-a-singleton.bad.al
@@ -0,0 +1,55 @@
+table 50366 "Loyalty Setup"
+{
+ Caption = 'Loyalty Setup';
+ DataClassification = CustomerContent;
+
+ fields
+ {
+ // Anti-pattern: an autoincrement key lets the table hold many rows,
+ // so "the setup" is no longer a single, well-known record.
+ field(1; "Entry No."; Integer)
+ {
+ Caption = 'Entry No.';
+ AutoIncrement = true;
+ }
+ field(10; "Member Nos."; Code[20])
+ {
+ Caption = 'Member Nos.';
+ TableRelation = "No. Series";
+ }
+ }
+
+ keys
+ {
+ key(PK; "Entry No.")
+ {
+ Clustered = true;
+ }
+ }
+}
+
+page 50367 "Loyalty Setup List"
+{
+ // Anti-pattern: a List page over a setup table invites multiple rows and
+ // never guarantees that a row exists to read.
+ Caption = 'Loyalty Setup List';
+ PageType = List;
+ SourceTable = "Loyalty Setup";
+ UsageCategory = Administration;
+ ApplicationArea = All;
+
+ layout
+ {
+ area(Content)
+ {
+ repeater(Group)
+ {
+ field("Member Nos."; Rec."Member Nos.")
+ {
+ ApplicationArea = All;
+ ToolTip = 'Specifies the number series used to assign member numbers.';
+ }
+ }
+ }
+ }
+}
diff --git a/microsoft/knowledge/data-modeling/setup-table-is-a-singleton.good.al b/microsoft/knowledge/data-modeling/setup-table-is-a-singleton.good.al
new file mode 100644
index 0000000..d299810
--- /dev/null
+++ b/microsoft/knowledge/data-modeling/setup-table-is-a-singleton.good.al
@@ -0,0 +1,70 @@
+table 50364 "Loyalty Setup"
+{
+ Caption = 'Loyalty Setup';
+ DataClassification = CustomerContent;
+
+ fields
+ {
+ field(1; "Primary Key"; Code[10])
+ {
+ Caption = 'Primary Key';
+ }
+ field(10; "Member Nos."; Code[20])
+ {
+ Caption = 'Member Nos.';
+ TableRelation = "No. Series";
+ }
+ }
+
+ keys
+ {
+ key(PK; "Primary Key")
+ {
+ Clustered = true;
+ }
+ }
+
+ procedure GetRecordOnce()
+ begin
+ if Rec.Get() then
+ exit;
+ Rec.Init();
+ Rec.Insert();
+ end;
+}
+
+page 50365 "Loyalty Setup"
+{
+ Caption = 'Loyalty Setup';
+ PageType = Card;
+ SourceTable = "Loyalty Setup";
+ UsageCategory = Administration;
+ ApplicationArea = All;
+ InsertAllowed = false;
+ DeleteAllowed = false;
+
+ layout
+ {
+ area(Content)
+ {
+ group(Numbering)
+ {
+ Caption = 'Numbering';
+ field("Member Nos."; Rec."Member Nos.")
+ {
+ ApplicationArea = All;
+ ToolTip = 'Specifies the number series used to assign member numbers.';
+ }
+ }
+ }
+ }
+
+ trigger OnOpenPage()
+ begin
+ Rec.Reset();
+ if not Rec.Get() then begin
+ Rec.Init();
+ Rec.Insert();
+ end;
+ end;
+}
diff --git a/microsoft/knowledge/data-modeling/setup-table-is-a-singleton.md b/microsoft/knowledge/data-modeling/setup-table-is-a-singleton.md
new file mode 100644
index 0000000..774963f
--- /dev/null
+++ b/microsoft/knowledge/data-modeling/setup-table-is-a-singleton.md
@@ -0,0 +1,28 @@
+---
+bc-version: [all]
+domain: data-modeling
+keywords: [setup-table, insertallowed, deleteallowed, getrecordonce, primary-key, card-page]
+technologies: [al]
+countries: [w1]
+application-area: [all]
+---
+
+# A setup table is a singleton: one blank-keyed row, no insert or delete
+
+## Description
+
+An application-area setup table (`Sales & Receivables Setup`, `Inventory Setup`, and any custom `* Setup`) holds exactly one record per company. Its primary key is a single `Code[10]` field named `Primary Key`, and the row's value is left blank. Nothing else identifies the row β there is only ever one.
+
+The setup **card** page enforces the singleton: `InsertAllowed = false` and `DeleteAllowed = false` stop a second row or an empty table, and the page guarantees the row exists on first open β typically `OnOpenPage` with `if not Rec.Get() then begin Rec.Init(); Rec.Insert(); end;`, or a `GetRecordOnce` helper on the table. Consuming code then reads it with a plain `Get()`. The read side needs no access optimization β see `singleton-setup-tables-need-no-access-optimization.md`.
+
+## Best Practice
+
+`Primary Key` `Code[10]` is the sole key; the setup is surfaced through a Card page with `InsertAllowed = false`, `DeleteAllowed = false`, and an open-time guard that inserts the blank row if it is missing.
+
+See sample: `setup-table-is-a-singleton.good.al`.
+
+## Anti Pattern
+
+An `Integer` / `AutoIncrement` key, a page that allows insert or delete, or a List page over the setup table. Any of these lets the table hold zero or many rows, so "the setup" becomes ambiguous and `Get()` may fail or read the wrong record.
+
+See sample: `setup-table-is-a-singleton.bad.al`.
diff --git a/microsoft/knowledge/data-modeling/use-no-series-codeunit-not-noseriesmanagement.bad.al b/microsoft/knowledge/data-modeling/use-no-series-codeunit-not-noseriesmanagement.bad.al
new file mode 100644
index 0000000..cfac379
--- /dev/null
+++ b/microsoft/knowledge/data-modeling/use-no-series-codeunit-not-noseriesmanagement.bad.al
@@ -0,0 +1,51 @@
+table 50363 "Loyalty Member"
+{
+ Caption = 'Loyalty Member';
+ DataClassification = CustomerContent;
+
+ fields
+ {
+ field(1; "No."; Code[20])
+ {
+ Caption = 'No.';
+
+ trigger OnValidate()
+ begin
+ if "No." = xRec."No." then
+ exit;
+ LoyaltySetup.Get();
+ // Obsolete-pending: NoSeriesManagement.TestManual raises a
+ // deprecation warning and is scheduled for removal.
+ NoSeriesMgt.TestManual(LoyaltySetup."Member Nos.");
+ "No. Series" := '';
+ end;
+ }
+ field(2; "No. Series"; Code[20])
+ {
+ Caption = 'No. Series';
+ Editable = false;
+ }
+ }
+
+ keys
+ {
+ key(PK; "No.")
+ {
+ Clustered = true;
+ }
+ }
+
+ var
+ LoyaltySetup: Record "Loyalty Setup";
+ NoSeriesMgt: Codeunit NoSeriesManagement;
+
+ trigger OnInsert()
+ begin
+ if "No." = '' then begin
+ LoyaltySetup.Get();
+ LoyaltySetup.TestField("Member Nos.");
+ // Obsolete-pending legacy assignment call; use codeunit "No. Series".
+ NoSeriesMgt.InitSeries(LoyaltySetup."Member Nos.", xRec."No. Series", 0D, "No.", "No. Series");
+ end;
+ end;
+}
diff --git a/microsoft/knowledge/data-modeling/use-no-series-codeunit-not-noseriesmanagement.good.al b/microsoft/knowledge/data-modeling/use-no-series-codeunit-not-noseriesmanagement.good.al
new file mode 100644
index 0000000..debe070
--- /dev/null
+++ b/microsoft/knowledge/data-modeling/use-no-series-codeunit-not-noseriesmanagement.good.al
@@ -0,0 +1,55 @@
+table 50362 "Loyalty Member"
+{
+ Caption = 'Loyalty Member';
+ DataClassification = CustomerContent;
+
+ fields
+ {
+ field(1; "No."; Code[20])
+ {
+ Caption = 'No.';
+
+ trigger OnValidate()
+ var
+ NoSeries: Codeunit "No. Series";
+ begin
+ if "No." = xRec."No." then
+ exit;
+ LoyaltySetup.Get();
+ if not NoSeries.IsManual(LoyaltySetup."Member Nos.") then
+ Error(ManualNosNotAllowedErr);
+ "No. Series" := '';
+ end;
+ }
+ field(2; "No. Series"; Code[20])
+ {
+ Caption = 'No. Series';
+ Editable = false;
+ TableRelation = "No. Series";
+ }
+ }
+
+ keys
+ {
+ key(PK; "No.")
+ {
+ Clustered = true;
+ }
+ }
+
+ var
+ LoyaltySetup: Record "Loyalty Setup";
+ ManualNosNotAllowedErr: Label 'Numbers are assigned automatically. Allow manual numbers on the No. Series to enter one by hand.';
+
+ trigger OnInsert()
+ var
+ NoSeries: Codeunit "No. Series";
+ begin
+ if "No." = '' then begin
+ LoyaltySetup.Get();
+ LoyaltySetup.TestField("Member Nos.");
+ "No. Series" := LoyaltySetup."Member Nos.";
+ "No." := NoSeries.GetNextNo("No. Series");
+ end;
+ end;
+}
diff --git a/microsoft/knowledge/data-modeling/use-no-series-codeunit-not-noseriesmanagement.md b/microsoft/knowledge/data-modeling/use-no-series-codeunit-not-noseriesmanagement.md
new file mode 100644
index 0000000..b4d19b9
--- /dev/null
+++ b/microsoft/knowledge/data-modeling/use-no-series-codeunit-not-noseriesmanagement.md
@@ -0,0 +1,28 @@
+---
+bc-version: [22..]
+domain: data-modeling
+keywords: [no-series, getnextno, ismanual, noseriesmanagement, obsolete-pending, testmanual]
+technologies: [al]
+countries: [w1]
+application-area: [all]
+---
+
+# Assign numbers with codeunit `"No. Series"`, not the obsolete `NoSeriesManagement`
+
+## Description
+
+Since 2023 release wave 1 (v22) the number-series API is codeunit **310** `"No. Series"`, called by name in AL. Its methods include `GetNextNo`, `PeekNextNo`, `IsManual`, `TestManual`, and `LookupRelatedNoSeries`. The older codeunit **396** `NoSeriesManagement` and its `InitSeries` / `SelectSeries` / `SetSeries` / `TestManual` methods are marked obsolete-pending: they still compile but raise a deprecation warning and are scheduled for removal, so they must not appear in new code.
+
+LLMs reproduce the legacy `NoSeriesManagement` pattern because it dominates pre-2023 training data. Prefer the new codeunit: it has a cleaner surface and is the only version that survives the deprecation. (The numbers matter β `310` is the current codeunit; `396` is the legacy one being retired.) Verify signatures on learn.microsoft.com or in the `microsoft/BCApps` source before use.
+
+## Best Practice
+
+`OnInsert` assigns the number with `NoSeries.GetNextNo("No. Series")` where `NoSeries` is `Codeunit "No. Series"`. The `No.` field's `OnValidate` guards manual entry by calling `NoSeries.IsManual(...)` (or `TestManual`) before clearing `No. Series`.
+
+See sample: `use-no-series-codeunit-not-noseriesmanagement.good.al`.
+
+## Anti Pattern
+
+`NoSeriesMgt.InitSeries(...)` for assignment and `NoSeriesMgt.TestManual(...)` for the manual check, where `NoSeriesMgt` is `Codeunit NoSeriesManagement`. Both are obsolete-pending and emit compiler warnings.
+
+See sample: `use-no-series-codeunit-not-noseriesmanagement.bad.al`.
diff --git a/microsoft/knowledge/error-handling/collect-validation-errors-with-errorbehavior.good.al b/microsoft/knowledge/error-handling/collect-validation-errors-with-errorbehavior.good.al
index dcd64b9..808dc69 100644
--- a/microsoft/knowledge/error-handling/collect-validation-errors-with-errorbehavior.good.al
+++ b/microsoft/knowledge/error-handling/collect-validation-errors-with-errorbehavior.good.al
@@ -15,10 +15,13 @@ codeunit 50185 "Collect Errors Good Sample"
until Item.Next() = 0;
if HasCollectedErrors() then begin
- CollectedErrors := GetCollectedErrors();
+ // The default is false; true retrieves and clears the collection.
+ CollectedErrors := GetCollectedErrors(true);
+ // This blocking aggregate intentionally retains messages only.
foreach CollectedError in CollectedErrors do
ErrorText += CollectedError.Message() + '\';
- Message('The following must be fixed before posting:\%1', ErrorText);
+ Error(ErrorInfo.Create(
+ StrSubstNo('The following must be fixed before posting:\%1', ErrorText), false));
end;
end;
}
@@ -30,8 +33,10 @@ codeunit 50186 "Collect Errors Item Check"
trigger OnRun()
begin
if Rec.Description = '' then
- Error('Item %1 has no description.', Rec."No.");
+ Error(ErrorInfo.Create(
+ StrSubstNo('Item %1 has no description.', Rec."No."), true));
if Rec."Unit Cost" <= 0 then
- Error('Item %1 must have a positive unit cost.', Rec."No.");
+ Error(ErrorInfo.Create(
+ StrSubstNo('Item %1 must have a positive unit cost.', Rec."No."), true));
end;
}
diff --git a/microsoft/knowledge/error-handling/collect-validation-errors-with-errorbehavior.md b/microsoft/knowledge/error-handling/collect-validation-errors-with-errorbehavior.md
index 6cc891c..b464fec 100644
--- a/microsoft/knowledge/error-handling/collect-validation-errors-with-errorbehavior.md
+++ b/microsoft/knowledge/error-handling/collect-validation-errors-with-errorbehavior.md
@@ -1,5 +1,5 @@
---
-bc-version: [all]
+bc-version: [19..]
domain: error-handling
keywords: [collectible-errors, errorbehavior, collect, getcollectederrors, hascollectederrors, validation, batch]
technologies: [al]
@@ -11,16 +11,16 @@ application-area: [all]
## Description
-By default a procedure stops on the first `Error`, so a user fixing ten bad rows must rerun the operation ten times. The collectible-errors feature postpones error handling to the end of the call: a procedure attributed `[ErrorBehavior(ErrorBehavior::Collect)]` keeps running as errors occur and gathers them, so all failures can be presented together. The collected errors are read with `HasCollectedErrors()` and `GetCollectedErrors()` (which returns a `List of [ErrorInfo]`); `ClearCollectedErrors()` empties the buffer. This is a platform mechanism most LLMs are unaware of β they reach for a manually concatenated `Text` buffer or a temporary error table instead.
+By default a procedure stops on the first `Error`, so a user fixing ten bad rows must rerun the operation ten times. The collectible-errors feature postpones error handling to the end of the call: a procedure attributed `[ErrorBehavior(ErrorBehavior::Collect)]` keeps running as collectible errors occur and gathers them, so all failures can be presented together. `GetCollectedErrors()` returns a `List of [ErrorInfo]` for the handler to inspect, but does not clear the collection by default; pass `true` to retrieve and clear in one call, or call `ClearCollectedErrors()` explicitly after retrieving. A handler can copy record information into a custom error page as Microsoft Learn demonstrates, or deliberately format only the messages into a final blocking error as this article's sample does.
## Best Practice
-Mark the orchestrating procedure `[ErrorBehavior(ErrorBehavior::Collect)]` and run each item's validation so one failure doesn't abandon the rest β typically by calling the per-item routine through `Codeunit.Run`. When the run finishes, inspect `HasCollectedErrors()` and surface `GetCollectedErrors()` to the user as a single list. Always handle the collected errors yourself: the platform's own guidance is that any errors still in the collected list when the procedure ends are concatenated into one dialog, which is hard for users to read.
+Mark the orchestrating procedure `[ErrorBehavior(ErrorBehavior::Collect)]` and run each item's validation so one failure doesn't abandon the rest β typically by calling the per-item routine through `Codeunit.Run`. When the run finishes, inspect `HasCollectedErrors()`, retrieve and clear the list with `GetCollectedErrors(true)`, and fail the operation with the collected messages. The sample intentionally produces a text aggregate and does not claim to retain record/field metadata in the final error. If that metadata is needed, map each `ErrorInfo` to a custom error UI before clearing, following the Microsoft Learn pattern. Do not replace validation failure with `Message`: clearing collected errors suppresses the platform failure, so the custom handler must still block the invalid operation.
See sample: `collect-validation-errors-with-errorbehavior.good.al`.
## Anti Pattern
-Two shapes signal trouble. The first is hand-rolled accumulation β appending messages to a `Text` variable and showing them at the end β which reimplements the platform feature, loses each error's `ErrorInfo` structure, and skips telemetry classification. The second is applying `[ErrorBehavior(ErrorBehavior::Collect)]` but never calling `HasCollectedErrors`/`GetCollectedErrors`, so every collected error spills into the platform's concatenated end-of-procedure dialog. Detection: a `Collect` attribute with no matching `GetCollectedErrors` call, or a per-row loop that builds an error string by concatenation.
+Three shapes signal trouble. Hand-rolled accumulation reimplements collection and prevents the handler from receiving individual `ErrorInfo` values. A `Collect` procedure that never handles the collection falls back to the concatenated platform dialog. Finally, code that calls parameterless `GetCollectedErrors()`, assumes it cleared the list, and only shows a `Message` can both leave the errors collected and allow invalid processing to continue.
See sample: `collect-validation-errors-with-errorbehavior.bad.al`.
diff --git a/microsoft/knowledge/error-handling/errortype-internal-vs-client-for-diagnostics.good.al b/microsoft/knowledge/error-handling/errortype-internal-vs-client-for-diagnostics.good.al
index 9791909..2dcd2d2 100644
--- a/microsoft/knowledge/error-handling/errortype-internal-vs-client-for-diagnostics.good.al
+++ b/microsoft/knowledge/error-handling/errortype-internal-vs-client-for-diagnostics.good.al
@@ -7,7 +7,6 @@ codeunit 50190 "Error Type Good Sample"
if not BucketInitialized(BucketId) then begin
InternalErr.ErrorType := ErrorType::Internal;
InternalErr.Message := StrSubstNo('Ledger bucket %1 was not initialized before posting.', BucketId);
- InternalErr.DetailedMessage := 'Internal invariant violated. Inspect the call stack captured in telemetry.';
Error(InternalErr);
end;
end;
diff --git a/microsoft/knowledge/error-handling/errortype-internal-vs-client-for-diagnostics.md b/microsoft/knowledge/error-handling/errortype-internal-vs-client-for-diagnostics.md
index 7264ad6..127fa50 100644
--- a/microsoft/knowledge/error-handling/errortype-internal-vs-client-for-diagnostics.md
+++ b/microsoft/knowledge/error-handling/errortype-internal-vs-client-for-diagnostics.md
@@ -1,5 +1,5 @@
---
-bc-version: [all]
+bc-version: [14..]
domain: error-handling
keywords: [errorinfo, errortype, internal, client, telemetry, diagnostics, generic-message]
technologies: [al]
@@ -15,7 +15,7 @@ application-area: [all]
## Best Practice
-Reserve `ErrorType::Internal` for errors the user cannot act on: corrupted internal state, an unreachable branch, a contract a caller violated. Set a precise, detail-rich `Message` and `DetailedMessage` for telemetry, raise it via `Error(ErrorInfo)`, and let the platform show the user a generic dialog. Keep `ErrorType::Client` (or a plain `Error`) for failures the user is expected to read and resolve β validation messages, missing setup, business-rule violations. The test is simple: if the message only makes sense to a developer, mark it `Internal`.
+Reserve `ErrorType::Internal` for errors the user cannot act on: corrupted internal state, an unreachable branch, a contract a caller violated. Set a precise, detail-rich `Message` for telemetry, raise it via `Error(ErrorInfo)`, and let the platform show the user a generic dialog. Keep `ErrorType::Client` (or a plain `Error`) for failures the user is expected to read and resolve β validation messages, missing setup, business-rule violations. The test is simple: if the message only makes sense to a developer, mark it `Internal`.
See sample: `errortype-internal-vs-client-for-diagnostics.good.al`.
diff --git a/microsoft/knowledge/error-handling/fielderror-default-message-logic.bad.al b/microsoft/knowledge/error-handling/fielderror-default-message-logic.bad.al
new file mode 100644
index 0000000..75d51f5
--- /dev/null
+++ b/microsoft/knowledge/error-handling/fielderror-default-message-logic.bad.al
@@ -0,0 +1,23 @@
+table 50120 "FieldError Default Bad"
+{
+ fields
+ {
+ field(1; "No."; Code[20]) { }
+ field(2; "Discount %"; Decimal) { }
+ field(3; "Currency Code"; Code[10]) { }
+ }
+
+ procedure ValidateForRelease()
+ begin
+ // This re-tests a field and gives FieldError a fully formed sentence.
+ // The framework already prepends the caption and appends the value,
+ // so this renders as "Currency Code The Currency Code field must have
+ // a value. in ..." β caption repeated, capital letter mid-sentence,
+ // stray trailing clause.
+ if "Currency Code" = '' then
+ FieldError("Currency Code", 'The Currency Code field must have a value.');
+
+ if "Discount %" > 100 then
+ FieldError("Discount %", 'The Discount % must not be greater than 100 percent.');
+ end;
+}
diff --git a/microsoft/knowledge/error-handling/fielderror-default-message-logic.good.al b/microsoft/knowledge/error-handling/fielderror-default-message-logic.good.al
new file mode 100644
index 0000000..b826194
--- /dev/null
+++ b/microsoft/knowledge/error-handling/fielderror-default-message-logic.good.al
@@ -0,0 +1,21 @@
+table 50120 "FieldError Default Good"
+{
+ fields
+ {
+ field(1; "No."; Code[20]) { }
+ field(2; "Discount %"; Decimal) { }
+ field(3; "Currency Code"; Code[10]) { }
+ }
+
+ procedure ValidateForRelease()
+ begin
+ // TestField checks this required-field condition and raises the error
+ // with caption and record context supplied by the framework.
+ TestField("Currency Code");
+
+ // Condition already evaluated: pass only a lowercase predicate so it
+ // reads as one sentence after the auto-inserted caption and value.
+ if "Discount %" > 100 then
+ FieldError("Discount %", 'cannot exceed 100');
+ end;
+}
diff --git a/microsoft/knowledge/error-handling/fielderror-default-message-logic.md b/microsoft/knowledge/error-handling/fielderror-default-message-logic.md
new file mode 100644
index 0000000..578092f
--- /dev/null
+++ b/microsoft/knowledge/error-handling/fielderror-default-message-logic.md
@@ -0,0 +1,18 @@
+---
+bc-version: [all]
+domain: error-handling
+keywords: [fielderror, testfield, error-message, field-caption, lowercase-convention, record-context, validation]
+technologies: [al]
+countries: [w1]
+application-area: [all]
+---
+# Rely On FieldError's Auto-Generated Context And Pass Only A Lowercase Predicate
+
+## Description
+`Rec.FieldError(FieldNo)` does not just print the text you give it. Business Central automatically prepends the field caption, appends the current field value (when non-blank), and suffixes the table name and primary-key values for record identification. The optional second argument is only the middle predicate of that sentence β e.g. `"must be unique"`, not a whole self-contained message. Misunderstanding this leads to messages that duplicate the caption and value or read as broken grammar, because the framework's surrounding text is built to join a lowercase fragment.
+
+## Best Practice
+For a plain required-field check, prefer `TestField`, which tests the condition and raises the error in one call. When the condition is non-trivial and has already been evaluated, call `FieldError(FieldNo)` with no message to get the localized default (`must have a value`, `is not valid`, etc.), or pass a short lowercase predicate such as `FieldError(FieldNo, 'must be a positive number')`. Start the custom text with a lowercase letter so it reads as one sentence with the auto-inserted caption, and use a field-number reference (or the field token) rather than a hard-coded field name so captions and translations stay correct. Let the framework supply the caption, value, table, and key context for you.
+
+## Anti Pattern
+Re-testing a condition you already evaluated, or passing a fully formed sentence like `'The Amount field must be positive.'` to `FieldError`. The result reads as `Amount The Amount field must be positive. in Gen. Journal Line ...` β capital letter mid-sentence, caption and value repeated, and a stray trailing clause. Reviewer signals: a `FieldError` argument that names the field, restates the current value, starts with a capital letter, or ends with a period. Each is a sign the author treated `FieldError` like `Error` instead of as a predicate slotted into framework-generated context.
\ No newline at end of file
diff --git a/microsoft/knowledge/error-handling/fielderror-vs-testfield.bad.al b/microsoft/knowledge/error-handling/fielderror-vs-testfield.bad.al
new file mode 100644
index 0000000..8ccf33b
--- /dev/null
+++ b/microsoft/knowledge/error-handling/fielderror-vs-testfield.bad.al
@@ -0,0 +1,26 @@
+table 50122 "FieldError vs TestField Bad"
+{
+ fields
+ {
+ field(1; "No."; Code[20]) { }
+ field(2; "Posting Date"; Date) { }
+ field(3; "Amount"; Decimal) { }
+ }
+
+ procedure PostDocument()
+ begin
+ // FieldError performs no comparison and raises as soon as it is
+ // reached, so this "check" terminates PostDocument every time β the
+ // Posting Date is never actually tested, and the amount rule below is
+ // dead code.
+ FieldError("Posting Date", 'must be filled in');
+
+ if IsAmountOutsideAllowedRange("Amount") then
+ Error('Amount is out of range.');
+ end;
+
+ local procedure IsAmountOutsideAllowedRange(Value: Decimal): Boolean
+ begin
+ exit((Value < 0) or (Value > 1000000));
+ end;
+}
diff --git a/microsoft/knowledge/error-handling/fielderror-vs-testfield.good.al b/microsoft/knowledge/error-handling/fielderror-vs-testfield.good.al
new file mode 100644
index 0000000..e39073d
--- /dev/null
+++ b/microsoft/knowledge/error-handling/fielderror-vs-testfield.good.al
@@ -0,0 +1,27 @@
+table 50122 "FieldError vs TestField Good"
+{
+ fields
+ {
+ field(1; "No."; Code[20]) { }
+ field(2; "Posting Date"; Date) { }
+ field(3; "Amount"; Decimal) { }
+ }
+
+ procedure PostDocument()
+ begin
+ // TestField performs this simple presence check and raises only when
+ // the field is empty. Self-documenting prerequisite.
+ TestField("Posting Date");
+
+ // Business logic has already determined the value is invalid;
+ // FieldError raises a tailored, record-aware message with no
+ // condition of its own.
+ if IsAmountOutsideAllowedRange("Amount") then
+ FieldError("Amount", 'is outside the approved posting range');
+ end;
+
+ local procedure IsAmountOutsideAllowedRange(Value: Decimal): Boolean
+ begin
+ exit((Value < 0) or (Value > 1000000));
+ end;
+}
diff --git a/microsoft/knowledge/error-handling/fielderror-vs-testfield.md b/microsoft/knowledge/error-handling/fielderror-vs-testfield.md
new file mode 100644
index 0000000..2208a18
--- /dev/null
+++ b/microsoft/knowledge/error-handling/fielderror-vs-testfield.md
@@ -0,0 +1,18 @@
+---
+bc-version: [all]
+domain: error-handling
+keywords: [fielderror, testfield, field-validation, onvalidate, error-message, mandatory-field, record-context]
+technologies: [al]
+countries: [w1]
+application-area: [all]
+---
+# Choose `TestField` For Conditional Checks And `FieldError` For Already-Failed Validation
+
+## Description
+`TestField` and `FieldError` look interchangeable but behave differently, and choosing the wrong one produces either dead code or a check that never fires. `TestField` performs the comparison itself and throws only when the field is empty or does not match the supplied value; `FieldError` performs no comparison and always raises an error the moment it is reached. Both attach the field caption and the record's primary-key context to the message automatically, which is why neither should be replaced by a hand-built `Error` call that interpolates the field name as a literal.
+
+## Best Practice
+Use `TestField` when the condition is a simple presence-or-equality check on a single field β mandatory-field gates and prerequisite checks at the top of a procedure read clearly and self-document intent. Use `FieldError` inside an `OnValidate` trigger or a validation procedure where surrounding business logic has already determined the value is invalid and you want a specific, custom message. Rely on the built-in field-and-record context both methods add rather than re-stating the field name in the text.
+
+## Anti Pattern
+Calling `FieldError` to "test" a field β placing it on a path that is reached unconditionally and expecting it to validate β terminates execution every time because `FieldError` never evaluates a condition. The inverse smell is reaching for `TestField` when the rule needs a tailored message, then bolting a vague generic string onto a check that cannot express the real business reason. A reviewer can spot the first by a `FieldError` that is not guarded by a preceding `if`, and the second by a `TestField` whose intent comment describes a condition more complex than presence or equality.
diff --git a/microsoft/knowledge/error-handling/ignored-tryfunction-return-disables-try-semantics.bad.al b/microsoft/knowledge/error-handling/ignored-tryfunction-return-disables-try-semantics.bad.al
new file mode 100644
index 0000000..8d6df99
--- /dev/null
+++ b/microsoft/knowledge/error-handling/ignored-tryfunction-return-disables-try-semantics.bad.al
@@ -0,0 +1,17 @@
+codeunit 50301 "Try Return Bad"
+{
+ procedure ImportDocument()
+ begin
+ // Ignoring the Boolean result makes this an ordinary, throwing call.
+ TryImportDocument();
+ end;
+
+ [TryFunction]
+ local procedure TryImportDocument()
+ begin
+ Error(SourceRejectedErr);
+ end;
+
+ var
+ SourceRejectedErr: Label 'The source document was rejected.';
+}
diff --git a/microsoft/knowledge/error-handling/ignored-tryfunction-return-disables-try-semantics.good.al b/microsoft/knowledge/error-handling/ignored-tryfunction-return-disables-try-semantics.good.al
new file mode 100644
index 0000000..d369d27
--- /dev/null
+++ b/microsoft/knowledge/error-handling/ignored-tryfunction-return-disables-try-semantics.good.al
@@ -0,0 +1,18 @@
+codeunit 50300 "Try Return Good"
+{
+ procedure ImportDocument()
+ begin
+ if not TryImportDocument() then
+ Error(ImportFailedErr);
+ end;
+
+ [TryFunction]
+ local procedure TryImportDocument()
+ begin
+ Error(SourceRejectedErr);
+ end;
+
+ var
+ ImportFailedErr: Label 'The document could not be imported.';
+ SourceRejectedErr: Label 'The source document was rejected.';
+}
diff --git a/microsoft/knowledge/error-handling/ignored-tryfunction-return-disables-try-semantics.md b/microsoft/knowledge/error-handling/ignored-tryfunction-return-disables-try-semantics.md
new file mode 100644
index 0000000..f61553d
--- /dev/null
+++ b/microsoft/knowledge/error-handling/ignored-tryfunction-return-disables-try-semantics.md
@@ -0,0 +1,26 @@
+---
+bc-version: [13..]
+domain: error-handling
+keywords: [tryfunction, try-method, boolean-return, ignored-return-value, error-propagation]
+technologies: [al]
+countries: [w1]
+application-area: [all]
+---
+
+# Consume a TryFunction return value to enable try semantics
+
+## Description
+
+A procedure marked `[TryFunction]` catches errors only when the caller uses its Boolean return value. An assignment or conditional makes the invocation a try-method call; a bare call is treated as an ordinary procedure call and exposes errors as usual. The attribute alone does not make every invocation non-throwing.
+
+## Best Practice
+
+Consume the result directly: assign it to a Boolean or use the call in an `if` condition. Handle `false` immediately while the last-error state still describes that failure.
+
+See sample: `ignored-tryfunction-return-disables-try-semantics.good.al`.
+
+## Anti Pattern
+
+Calling a `[TryFunction]` procedure as a standalone statement and assuming the attribute suppresses its errors. The call has ordinary error semantics because its Boolean result is ignored.
+
+See sample: `ignored-tryfunction-return-disables-try-semantics.bad.al`.
diff --git a/microsoft/knowledge/events/add-new-event-parameters-at-the-end.bad.al b/microsoft/knowledge/events/add-new-event-parameters-at-the-end.bad.al
index 19ca979..e146be7 100644
--- a/microsoft/knowledge/events/add-new-event-parameters-at-the-end.bad.al
+++ b/microsoft/knowledge/events/add-new-event-parameters-at-the-end.bad.al
@@ -1,21 +1,14 @@
-// Demonstration-only AL. Not compiled by CI; illustrates the article.
+// Demonstration-only AL. Version 1 exposed PostDocument(SalesHeader).
codeunit 50251 "Param Append Bad Sample"
{
- procedure PostDocument(var SalesHeader: Record "Sales Header"; CalledFromBatch: Boolean)
- var
- IsHandled: Boolean;
+ procedure PostDocument(var SalesHeader: Record "Sales Header")
begin
- IsHandled := false;
- // Anti-pattern: 'CalledFromBatch' was inserted before the existing
- // IsHandled parameter, shifting it and breaking the argument positions
- // every existing subscriber relied on.
- OnBeforePostDocument(SalesHeader, CalledFromBatch, IsHandled);
- if IsHandled then
- exit;
+ // Existing callers cannot supply the newly required argument.
+ OnBeforePostDocument(SalesHeader);
end;
[IntegrationEvent(false, false)]
- local procedure OnBeforePostDocument(var SalesHeader: Record "Sales Header"; CalledFromBatch: Boolean; var IsHandled: Boolean)
+ procedure OnBeforePostDocument(var SalesHeader: Record "Sales Header"; CalledFromBatch: Boolean)
begin
end;
}
diff --git a/microsoft/knowledge/events/add-new-event-parameters-at-the-end.good.al b/microsoft/knowledge/events/add-new-event-parameters-at-the-end.good.al
index 8a13087..6c3a459 100644
--- a/microsoft/knowledge/events/add-new-event-parameters-at-the-end.good.al
+++ b/microsoft/knowledge/events/add-new-event-parameters-at-the-end.good.al
@@ -1,4 +1,4 @@
-// Demonstration-only AL. Not compiled by CI; illustrates the article.
+// Demonstration-only AL. Version 1 had SalesHeader and IsHandled parameters.
codeunit 50250 "Param Append Good Sample"
{
procedure PostDocument(var SalesHeader: Record "Sales Header"; CalledFromBatch: Boolean)
@@ -6,15 +6,24 @@ codeunit 50250 "Param Append Good Sample"
IsHandled: Boolean;
begin
IsHandled := false;
- // The new 'CalledFromBatch' parameter was appended at the end of the
- // existing signature, so existing subscribers needed no re-mapping.
- OnBeforePostDocument(SalesHeader, IsHandled, CalledFromBatch);
+ // Subscribers bind by name, so the new parameter can sit between the
+ // existing parameters without breaking subscribers that omit it.
+ OnBeforePostDocument(SalesHeader, CalledFromBatch, IsHandled);
if IsHandled then
exit;
end;
[IntegrationEvent(false, false)]
- local procedure OnBeforePostDocument(var SalesHeader: Record "Sales Header"; var IsHandled: Boolean; CalledFromBatch: Boolean)
+ local procedure OnBeforePostDocument(var SalesHeader: Record "Sales Header"; CalledFromBatch: Boolean; var IsHandled: Boolean)
begin
end;
}
+
+codeunit 50252 "Existing Param Subscriber"
+{
+ [EventSubscriber(ObjectType::Codeunit, Codeunit::"Param Append Good Sample", 'OnBeforePostDocument', '', false, false)]
+ local procedure OnBeforePostDocument(var SalesHeader: Record "Sales Header"; var IsHandled: Boolean)
+ begin
+ IsHandled := SalesHeader."No." = '';
+ end;
+}
diff --git a/microsoft/knowledge/events/add-new-event-parameters-at-the-end.md b/microsoft/knowledge/events/add-new-event-parameters-at-the-end.md
index 1f1dc14..b05b020 100644
--- a/microsoft/knowledge/events/add-new-event-parameters-at-the-end.md
+++ b/microsoft/knowledge/events/add-new-event-parameters-at-the-end.md
@@ -1,26 +1,26 @@
---
bc-version: [all]
domain: events
-keywords: [event-parameters, signature, backward-compatibility, append, onbefore, integration-event, versioning]
+keywords: [event-parameters, signature, backward-compatibility, public-event, local-event, internal-event, appsourcecop, as0024, as0025]
technologies: [al]
countries: [w1]
application-area: [all]
---
-# Add new event parameters at the end
+# Event parameter additions depend on publisher access, not position
## Description
-Adding a parameter to an existing event publisher changes its signature. Appending the new parameter at the end of the parameter list keeps the change easy to review and track: existing subscribers still bind to the leading parameters, and the diff is a single clean addition. Inserting a parameter in the middle makes diffs noisy and harder to review, and obscures the history of how the signature evolved. New parameters belong after the existing ones.
+Event subscribers bind publisher parameters by name and can omit parameters they do not use. A `local` or `internal` Business or Integration event can therefore gain a parameter at any position without breaking subscriber-only consumers; appending is not a compatibility requirement. A public event is also a public procedure that dependent extensions can raise, so adding a required parameter anywhere breaks callers under AppSourceCop AS0024.
## Best Practice
-When extending an existing publisher, append the new parameter after all existing ones, including after a trailing `var IsHandled: Boolean` when present. Subscribers that already match keep working against the leading parameters, and the change stays a one-line addition that is trivial to review.
+Add a parameter directly only when the shipped event publisher is `local` or `internal`. Place it where the signature is clearest; existing subscribers continue binding the parameters they name. For a public event, keep the original publisher unchanged and introduce a new event with the expanded contract.
See sample: `add-new-event-parameters-at-the-end.good.al`.
## Anti Pattern
-Inserting a new parameter in the middle of an existing event's signature, shifting every subsequent parameter and making the change noisy and harder to review. Detection: a changed event signature where an added parameter appears before existing parameters rather than at the tail of the list.
+Appending a parameter to a public event and assuming its position makes the change compatible. Existing external callers still lack the new required argument. Conversely, do not flag a parameter inserted among existing parameters on a `local` or `internal` Business or Integration event merely because it was not appended.
See sample: `add-new-event-parameters-at-the-end.bad.al`.
diff --git a/community/knowledge/events/avoid-raising-events-inside-try-functions.bad.al b/microsoft/knowledge/events/avoid-raising-events-inside-try-functions.bad.al
similarity index 100%
rename from community/knowledge/events/avoid-raising-events-inside-try-functions.bad.al
rename to microsoft/knowledge/events/avoid-raising-events-inside-try-functions.bad.al
diff --git a/community/knowledge/events/avoid-raising-events-inside-try-functions.good.al b/microsoft/knowledge/events/avoid-raising-events-inside-try-functions.good.al
similarity index 100%
rename from community/knowledge/events/avoid-raising-events-inside-try-functions.good.al
rename to microsoft/knowledge/events/avoid-raising-events-inside-try-functions.good.al
diff --git a/community/knowledge/events/avoid-raising-events-inside-try-functions.md b/microsoft/knowledge/events/avoid-raising-events-inside-try-functions.md
similarity index 100%
rename from community/knowledge/events/avoid-raising-events-inside-try-functions.md
rename to microsoft/knowledge/events/avoid-raising-events-inside-try-functions.md
diff --git a/microsoft/knowledge/events/do-not-add-ishandled-to-an-existing-event.bad.al b/microsoft/knowledge/events/do-not-add-ishandled-to-an-existing-event.bad.al
index 8c93c25..f9bf0ef 100644
--- a/microsoft/knowledge/events/do-not-add-ishandled-to-an-existing-event.bad.al
+++ b/microsoft/knowledge/events/do-not-add-ishandled-to-an-existing-event.bad.al
@@ -8,9 +8,9 @@ codeunit 50291 "New OnBefore Bad Sample"
begin
Total := 100;
- // Anti-pattern: IsHandled was bolted onto the existing
- // OnAfterCalculateTotal, changing its contract and breaking every
- // subscriber that matched the original signature.
+ // Anti-pattern: IsHandled was bolted onto the existing OnAfter event.
+ // Regardless of compiler compatibility, this changes a notification
+ // into an override contract that existing subscribers did not expect.
OnAfterCalculateTotal(SalesHeader, Total, IsHandled);
end;
diff --git a/microsoft/knowledge/events/do-not-change-shipped-event-attribute-flags.bad.al b/microsoft/knowledge/events/do-not-change-shipped-event-attribute-flags.bad.al
new file mode 100644
index 0000000..ee7adf8
--- /dev/null
+++ b/microsoft/knowledge/events/do-not-change-shipped-event-attribute-flags.bad.al
@@ -0,0 +1,14 @@
+// Demonstration-only AL. Version 1 used [IntegrationEvent(true, true, false)].
+codeunit 50531 "Shipment Events Bad"
+{
+ procedure NotifyShipment(ShipmentNo: Code[20])
+ begin
+ OnShipmentCreated(ShipmentNo);
+ end;
+
+ // Version 2 mutates all three contract-significant arguments in place.
+ [IntegrationEvent(false, false, true)]
+ local procedure OnShipmentCreated(ShipmentNo: Code[20])
+ begin
+ end;
+}
diff --git a/microsoft/knowledge/events/do-not-change-shipped-event-attribute-flags.good.al b/microsoft/knowledge/events/do-not-change-shipped-event-attribute-flags.good.al
new file mode 100644
index 0000000..6ff7646
--- /dev/null
+++ b/microsoft/knowledge/events/do-not-change-shipped-event-attribute-flags.good.al
@@ -0,0 +1,21 @@
+// Demonstration-only AL. The Isolated argument requires runtime 9.0 / BC20.
+codeunit 50530 "Shipment Events"
+{
+ procedure NotifyShipment(ShipmentNo: Code[20])
+ begin
+ OnShipmentCreated(ShipmentNo);
+ OnShipmentCreatedIsolated(ShipmentNo);
+ end;
+
+ // Preserve the shipped attribute contract.
+ [IntegrationEvent(true, true, false)]
+ local procedure OnShipmentCreated(ShipmentNo: Code[20])
+ begin
+ end;
+
+ // Publish a new event for different isolation and sender semantics.
+ [IntegrationEvent(false, false, true)]
+ local procedure OnShipmentCreatedIsolated(ShipmentNo: Code[20])
+ begin
+ end;
+}
diff --git a/microsoft/knowledge/events/do-not-change-shipped-event-attribute-flags.md b/microsoft/knowledge/events/do-not-change-shipped-event-attribute-flags.md
new file mode 100644
index 0000000..98e0ca7
--- /dev/null
+++ b/microsoft/knowledge/events/do-not-change-shipped-event-attribute-flags.md
@@ -0,0 +1,26 @@
+---
+bc-version: [all]
+domain: events
+keywords: [event-attribute, includesender, globalvaraccess, isolated-event, compatibility, integration-event, business-event, appsourcecop, as0021, as0101]
+technologies: [al]
+countries: [w1]
+application-area: [all]
+---
+
+# Do not change shipped event attribute flags
+
+## Description
+
+`IncludeSender` and, on Integration events, `GlobalVarAccess` have been event-contract flags since runtime 1.0. Removing sender or global access breaks subscribers, so AppSourceCop AS0021 prevents changing those flags from `true` to `false`. On runtime 9.0 and later (Business Central 2022 release wave 1, BC20), `Isolated` also controls transaction, error, and rollback behavior; AS0101 prevents adding, removing, or changing that argument.
+
+## Best Practice
+
+Keep every available attribute argument exactly as shipped. If new subscribers need different sender/global exposure, publish a new event with the desired flags. Apply the same rule to `Isolated` only on BC20 or later, where that argument exists. Raise both events while the original contract is supported, and choose preferred flags only when designing a new event.
+
+See sample: `do-not-change-shipped-event-attribute-flags.good.al`.
+
+## Anti Pattern
+
+Changing a shipped event's `IncludeSender` or `GlobalVarAccess` to modernize its design, including replacing `IncludeSender` with an explicit parameter. On BC20 or later, adding, removing, or toggling `Isolated` is equally contract-significant. Even a change that leaves old subscribers compiling can alter observable execution or exposure; version the event instead.
+
+See sample: `do-not-change-shipped-event-attribute-flags.bad.al`.
diff --git a/microsoft/knowledge/events/prefer-reusing-or-extending-existing-events.good.al b/microsoft/knowledge/events/prefer-reusing-or-extending-existing-events.good.al
index 0d64906..8063c9d 100644
--- a/microsoft/knowledge/events/prefer-reusing-or-extending-existing-events.good.al
+++ b/microsoft/knowledge/events/prefer-reusing-or-extending-existing-events.good.al
@@ -8,13 +8,13 @@ codeunit 50260 "Reuse Event Good Sample"
IsHandled := false;
// A single event, extended with CustomerNo appended at the end, covers
// the need; no second event is raised beside it.
- OnBeforeProcessOrder(SalesHeader, CustomerNo, IsHandled);
+ OnBeforeProcessOrder(SalesHeader, IsHandled, CustomerNo);
if IsHandled then
exit;
end;
[IntegrationEvent(false, false)]
- local procedure OnBeforeProcessOrder(var SalesHeader: Record "Sales Header"; CustomerNo: Code[20]; var IsHandled: Boolean)
+ local procedure OnBeforeProcessOrder(var SalesHeader: Record "Sales Header"; var IsHandled: Boolean; CustomerNo: Code[20])
begin
end;
}
diff --git a/microsoft/knowledge/events/prefer-this-over-includesender-in-codeunit-events.md b/microsoft/knowledge/events/prefer-this-over-includesender-in-codeunit-events.md
index c32bd0d..35d75dc 100644
--- a/microsoft/knowledge/events/prefer-this-over-includesender-in-codeunit-events.md
+++ b/microsoft/knowledge/events/prefer-this-over-includesender-in-codeunit-events.md
@@ -7,20 +7,20 @@ countries: [w1]
application-area: [all]
---
-# Prefer this over IncludeSender in codeunit events
+# Prefer this over IncludeSender in new codeunit events
## Description
-Some publishers set `IncludeSender` to `true` on `[IntegrationEvent]` or `[BusinessEvent]` so subscribers receive the publishing object as an implicit sender parameter. From Business Central 2024 release wave 2, a codeunit can instead pass itself explicitly with the `this` keyword as a normal, strongly-typed `Sender` parameter. Explicit passing is clearer at both the publisher and the subscriber: the sender appears in the signature, it is concretely typed to the publishing codeunit, and it avoids the implicit-parameter mechanics of `IncludeSender`. Reserve `IncludeSender = true` for cases where the sender genuinely cannot be passed explicitly. This guidance applies to code targeting Business Central 2024 release wave 2 or later, where the `this` keyword is available.
+When designing a new publisher, setting `IncludeSender` to `true` on `[IntegrationEvent]` or `[BusinessEvent]` gives subscribers the publishing object as an implicit sender parameter. From Business Central 2024 release wave 2, a codeunit can instead pass itself explicitly with the `this` keyword as a normal, strongly-typed `Sender` parameter. Explicit passing makes the sender visible and typed in the signature. This is new-event design guidance only: never change `IncludeSender` on an event that has already shipped.
## Best Practice
-Declare the publisher `[IntegrationEvent(false, false)]` with an explicit `Sender: Codeunit "β¦"` parameter and raise it with `this`, for example `OnBeforeProcessOrder(OrderNo, this);`. Subscribers then receive a typed sender they can call directly.
+For a new event, declare the publisher `[IntegrationEvent(false, false)]` with an explicit `Sender: Codeunit "β¦"` parameter and raise it with `this`, for example `OnBeforeProcessOrder(OrderNo, this);`. Subscribers then receive a typed sender they can call directly.
See sample: `prefer-this-over-includesender-in-codeunit-events.good.al`.
## Anti Pattern
-Relying on `[IntegrationEvent(true, β¦)]` solely to hand subscribers the publisher instance, where a codeunit could pass `this` explicitly as a typed parameter. Detection: `IncludeSender = true` on a codeunit event whose only purpose is to expose the sender, in code targeting Business Central 2024 release wave 2 or later.
+Designing a new codeunit event with `[IntegrationEvent(true, β¦)]` solely to hand subscribers the publisher instance, where `this` could be passed explicitly as a typed parameter. Do not apply this rule by mutating a shipped event's attribute flags.
See sample: `prefer-this-over-includesender-in-codeunit-events.bad.al`.
diff --git a/microsoft/knowledge/events/publish-thin-onbefore-onafter-integration-events.good.al b/microsoft/knowledge/events/publish-thin-onbefore-onafter-integration-events.good.al
index ef67a3e..b7e3893 100644
--- a/microsoft/knowledge/events/publish-thin-onbefore-onafter-integration-events.good.al
+++ b/microsoft/knowledge/events/publish-thin-onbefore-onafter-integration-events.good.al
@@ -20,13 +20,14 @@ codeunit 50225 "Reservation Post Good Sample"
var
IsHandled: Boolean;
begin
+ IsHandled := false;
OnBeforeReserve(ReservationEntry, IsHandled);
- if IsHandled then
- exit;
-
- ReservationEntry.Reserved := true;
- ReservationEntry.Modify(true);
+ if not IsHandled then begin
+ ReservationEntry.Reserved := true;
+ ReservationEntry.Modify(true);
+ end;
+ // OnAfter reports completion whether a subscriber or the base body handled it.
OnAfterReserve(ReservationEntry);
end;
diff --git a/microsoft/knowledge/events/treat-local-and-internal-events-as-subscriber-contracts.bad.al b/microsoft/knowledge/events/treat-local-and-internal-events-as-subscriber-contracts.bad.al
new file mode 100644
index 0000000..9733ee5
--- /dev/null
+++ b/microsoft/knowledge/events/treat-local-and-internal-events-as-subscriber-contracts.bad.al
@@ -0,0 +1,15 @@
+// Demonstration-only AL. Version 1 exposed var Score as an Integer.
+codeunit 50521 "Customer Scoring Events Bad"
+{
+ procedure ScoreCustomer(CustomerNo: Code[20]; ScoreText: Text)
+ begin
+ OnCustomerScored(CustomerNo, ScoreText);
+ end;
+
+ // 'local' limits raising, not subscription. Renaming Score to ScoreText,
+ // changing its type, and removing var all break existing subscribers.
+ [IntegrationEvent(false, false)]
+ local procedure OnCustomerScored(CustomerNo: Code[20]; ScoreText: Text)
+ begin
+ end;
+}
diff --git a/microsoft/knowledge/events/treat-local-and-internal-events-as-subscriber-contracts.good.al b/microsoft/knowledge/events/treat-local-and-internal-events-as-subscriber-contracts.good.al
new file mode 100644
index 0000000..10185b7
--- /dev/null
+++ b/microsoft/knowledge/events/treat-local-and-internal-events-as-subscriber-contracts.good.al
@@ -0,0 +1,24 @@
+// Demonstration-only AL. Version 1 had CustomerNo and var Score parameters.
+codeunit 50520 "Customer Scoring Events"
+{
+ procedure ScoreCustomer(CustomerNo: Code[20]; Reason: Text; var Score: Integer)
+ begin
+ OnCustomerScored(CustomerNo, Reason, Score);
+ end;
+
+ // Adding Reason between existing parameters preserves subscriber bindings.
+ [IntegrationEvent(false, false)]
+ local procedure OnCustomerScored(CustomerNo: Code[20]; Reason: Text; var Score: Integer)
+ begin
+ end;
+}
+
+codeunit 50522 "Existing Scoring Subscriber"
+{
+ [EventSubscriber(ObjectType::Codeunit, Codeunit::"Customer Scoring Events", 'OnCustomerScored', '', false, false)]
+ local procedure OnCustomerScored(CustomerNo: Code[20]; var Score: Integer)
+ begin
+ if CustomerNo = '' then
+ Score := 0;
+ end;
+}
diff --git a/microsoft/knowledge/events/treat-local-and-internal-events-as-subscriber-contracts.md b/microsoft/knowledge/events/treat-local-and-internal-events-as-subscriber-contracts.md
new file mode 100644
index 0000000..95a2617
--- /dev/null
+++ b/microsoft/knowledge/events/treat-local-and-internal-events-as-subscriber-contracts.md
@@ -0,0 +1,26 @@
+---
+bc-version: [all]
+domain: events
+keywords: [local-event, internal-event, event-subscriber, compatibility, access-modifier, integration-event, business-event, parameter-name, var-parameter, appsourcecop]
+technologies: [al]
+countries: [w1]
+application-area: [all]
+---
+
+# Treat local and internal events as subscriber contracts
+
+## Description
+
+The `local` and `internal` access modifiers on Business and Integration event publishers restrict who can raise the procedure; they do not prevent dependent extensions from subscribing. Once shipped, the event name and each existing parameter's name, type/subtype, and value-versus-`var` passing mode are compatibility contracts even when the publisher is not public. Parameter order is not a subscriber contract because subscribers bind the parameters they use by name. This differs from `[InternalEvent]`, which is module-only except for modules named by `internalsVisibleTo`.
+
+## Best Practice
+
+Preserve a shipped Business or Integration event's identity and every existing parameter's name, type/subtype, and passing mode regardless of the procedure access modifier. AS0025 protects names and types, while AS0063 and AS0077 protect removal and addition of `var`. New parameters may be added at any position on a `local` or `internal` event because subscribers can omit them; public event procedures follow the stricter caller contract described by `add-new-event-parameters-at-the-end`.
+
+See sample: `treat-local-and-internal-events-as-subscriber-contracts.good.al`.
+
+## Anti Pattern
+
+Renaming or removing an existing parameter, changing its type/subtype, or adding/removing its `var` modifier because the event publisher procedure is `local` or `internal`. AppSourceCop checks these subscriber-breaking changes because dependent event subscribers can still bind to the event. Reordering unchanged parameters, or inserting a new parameter among them, is not this anti-pattern.
+
+See sample: `treat-local-and-internal-events-as-subscriber-contracts.bad.al`.
diff --git a/microsoft/knowledge/events/use-ishandled-to-make-base-behaviour-overridable.good.al b/microsoft/knowledge/events/use-ishandled-to-make-base-behaviour-overridable.good.al
index 6b47535..07913fa 100644
--- a/microsoft/knowledge/events/use-ishandled-to-make-base-behaviour-overridable.good.al
+++ b/microsoft/knowledge/events/use-ishandled-to-make-base-behaviour-overridable.good.al
@@ -7,6 +7,7 @@ codeunit 50220 "Shipping Charge Good Sample"
begin
// Give extensions a sanctioned seam to replace the calculation, then
// skip the default logic when a subscriber has handled it.
+ IsHandled := false;
OnBeforeCalculateShippingCharge(OrderAmount, Charge, IsHandled);
if IsHandled then
exit(Charge);
diff --git a/microsoft/knowledge/interfaces/extend-published-interfaces-dont-edit-them.bad.al b/microsoft/knowledge/interfaces/extend-published-interfaces-dont-edit-them.bad.al
new file mode 100644
index 0000000..bb630e3
--- /dev/null
+++ b/microsoft/knowledge/interfaces/extend-published-interfaces-dont-edit-them.bad.al
@@ -0,0 +1,16 @@
+// Demonstration-only AL. Version 1 shipped with only CalculateAmount().
+interface "I Shipping Quote Bad"
+{
+ procedure CalculateAmount(): Decimal;
+
+ // Added in version 2: every existing implementer now fails to compile.
+ procedure CalculateDeliveryDate(): Date;
+}
+
+codeunit 50511 "Existing Shipping Quote" implements "I Shipping Quote Bad"
+{
+ procedure CalculateAmount(): Decimal
+ begin
+ exit(10);
+ end;
+}
diff --git a/microsoft/knowledge/interfaces/extend-published-interfaces-dont-edit-them.good.al b/microsoft/knowledge/interfaces/extend-published-interfaces-dont-edit-them.good.al
new file mode 100644
index 0000000..a45efa6
--- /dev/null
+++ b/microsoft/knowledge/interfaces/extend-published-interfaces-dont-edit-them.good.al
@@ -0,0 +1,23 @@
+// Demonstration-only AL. Interface inheritance requires runtime 14.0 / BC25.
+interface "I Shipping Quote"
+{
+ procedure CalculateAmount(): Decimal;
+}
+
+interface "I Shipping Quote V2" extends "I Shipping Quote"
+{
+ procedure CalculateDeliveryDate(): Date;
+}
+
+codeunit 50510 "Shipping Quote V2" implements "I Shipping Quote V2"
+{
+ procedure CalculateAmount(): Decimal
+ begin
+ exit(10);
+ end;
+
+ procedure CalculateDeliveryDate(): Date
+ begin
+ exit(Today() + 1);
+ end;
+}
diff --git a/microsoft/knowledge/interfaces/extend-published-interfaces-dont-edit-them.md b/microsoft/knowledge/interfaces/extend-published-interfaces-dont-edit-them.md
new file mode 100644
index 0000000..2aceec4
--- /dev/null
+++ b/microsoft/knowledge/interfaces/extend-published-interfaces-dont-edit-them.md
@@ -0,0 +1,26 @@
+---
+bc-version: [16..]
+domain: interfaces
+keywords: [published-interface, interface-method, breaking-change, interface-extends, versioned-interface, appsourcecop, as0066]
+technologies: [al]
+countries: [w1]
+application-area: [all]
+---
+
+# Extend published interfaces; do not edit them
+
+## Description
+
+Adding a method to a shipped interface changes the contract every implementing codeunit must satisfy. Implementers can live in dependent extensions, so the addition breaks code the interface publisher cannot update; AppSourceCop reports AS0066. Interface inheritance is available from runtime 14.0 (Business Central 2024 release wave 2, BC25), but the original interface must remain unchanged.
+
+## Best Practice
+
+On BC25 or later, declare a new interface that `extends` the published interface and add the new method there. Existing implementers remain valid for the original contract, while new implementers opt in to the extended contract. For targets BC16 through BC24, where interface inheritance is unavailable, publish a new or versioned sibling interface instead.
+
+See sample: `extend-published-interfaces-dont-edit-them.good.al`.
+
+## Anti Pattern
+
+Adding a procedure directly to an interface that has already shipped. Every dependent implementation must immediately add that procedure, so an otherwise compatible app update breaks its implementers.
+
+See sample: `extend-published-interfaces-dont-edit-them.bad.al`.
diff --git a/microsoft/knowledge/interfaces/handle-unknown-enum-ordinals-with-unknownvalueimplementation.bad.al b/microsoft/knowledge/interfaces/handle-unknown-enum-ordinals-with-unknownvalueimplementation.bad.al
new file mode 100644
index 0000000..d815bea
--- /dev/null
+++ b/microsoft/knowledge/interfaces/handle-unknown-enum-ordinals-with-unknownvalueimplementation.bad.al
@@ -0,0 +1,36 @@
+// Demonstration-only AL. A removed enum-extension value left ordinal 700 in data.
+enum 50503 "Delivery Method Bad" implements "I Delivery Method Bad"
+{
+ Extensible = true;
+ DefaultImplementation = "I Delivery Method Bad" = "Default Delivery Method Bad";
+
+ value(0; Default)
+ {
+ }
+}
+
+interface "I Delivery Method Bad"
+{
+ procedure Deliver();
+}
+
+codeunit 50504 "Default Delivery Method Bad" implements "I Delivery Method Bad"
+{
+ procedure Deliver()
+ begin
+ end;
+}
+
+codeunit 50505 "Delivery Dispatch Bad"
+{
+ procedure DeliverPersistedValue()
+ var
+ DeliveryMethod: Enum "Delivery Method Bad";
+ Delivery: Interface "I Delivery Method Bad";
+ begin
+ DeliveryMethod := 700;
+ // DefaultImplementation does not handle an ordinal that is not declared.
+ Delivery := DeliveryMethod;
+ Delivery.Deliver();
+ end;
+}
diff --git a/microsoft/knowledge/interfaces/handle-unknown-enum-ordinals-with-unknownvalueimplementation.good.al b/microsoft/knowledge/interfaces/handle-unknown-enum-ordinals-with-unknownvalueimplementation.good.al
new file mode 100644
index 0000000..9df34ff
--- /dev/null
+++ b/microsoft/knowledge/interfaces/handle-unknown-enum-ordinals-with-unknownvalueimplementation.good.al
@@ -0,0 +1,34 @@
+// Demonstration-only AL. UnknownValueImplementation requires runtime 7.0 / BC18.
+interface "I Delivery Method"
+{
+ procedure Deliver();
+}
+
+codeunit 50500 "Unknown Delivery Method" implements "I Delivery Method"
+{
+ procedure Deliver()
+ begin
+ Error(UnknownMethodErr);
+ end;
+
+ var
+ UnknownMethodErr: Label 'The saved delivery method is no longer installed. Select another method.';
+}
+
+codeunit 50501 "Default Delivery Method" implements "I Delivery Method"
+{
+ procedure Deliver()
+ begin
+ end;
+}
+
+enum 50502 "Delivery Method" implements "I Delivery Method"
+{
+ Extensible = true;
+ DefaultImplementation = "I Delivery Method" = "Default Delivery Method";
+ UnknownValueImplementation = "I Delivery Method" = "Unknown Delivery Method";
+
+ value(0; Default)
+ {
+ }
+}
diff --git a/microsoft/knowledge/interfaces/handle-unknown-enum-ordinals-with-unknownvalueimplementation.md b/microsoft/knowledge/interfaces/handle-unknown-enum-ordinals-with-unknownvalueimplementation.md
new file mode 100644
index 0000000..d3715e6
--- /dev/null
+++ b/microsoft/knowledge/interfaces/handle-unknown-enum-ordinals-with-unknownvalueimplementation.md
@@ -0,0 +1,26 @@
+---
+bc-version: [18..]
+domain: interfaces
+keywords: [unknownvalueimplementation, unknown-enum-value, persisted-ordinal, enum-extension, extension-uninstall, interface-fallback]
+technologies: [al]
+countries: [w1]
+application-area: [all]
+---
+
+# Handle unknown enum ordinals with UnknownValueImplementation
+
+## Description
+
+An enum ordinal can remain in persisted data after the enum extension that declared it is uninstalled. The ordinal is then unknown: it matches no currently declared enum value. `DefaultImplementation` does not cover this case; it covers declared values that have no explicit interface implementation. `UnknownValueImplementation`, available from runtime 7.0 (Business Central 2021 release wave 1, BC18), provides the distinct interface implementation for an unknown ordinal.
+
+## Best Practice
+
+On BC18 or later, set `UnknownValueImplementation = = ;` on an enum that implements an interface and can be persisted. Use an implementation that reports a clear domain error or safely contains the unknown state. Keep `DefaultImplementation` separately when declared but unmapped values also need a fallback.
+
+See sample: `handle-unknown-enum-ordinals-with-unknownvalueimplementation.good.al`.
+
+## Anti Pattern
+
+Defining only `DefaultImplementation` and assuming it also handles a stored ordinal whose enum value has disappeared. After an enum extension is uninstalled, converting that unknown ordinal to the interface can produce a technical runtime error instead of controlled handling.
+
+See sample: `handle-unknown-enum-ordinals-with-unknownvalueimplementation.bad.al`.
diff --git a/microsoft/knowledge/interfaces/set-defaultimplementation-on-enum.md b/microsoft/knowledge/interfaces/set-defaultimplementation-on-enum.md
index 92ef3cf..7d2523e 100644
--- a/microsoft/knowledge/interfaces/set-defaultimplementation-on-enum.md
+++ b/microsoft/knowledge/interfaces/set-defaultimplementation-on-enum.md
@@ -11,11 +11,11 @@ application-area: [all]
## Description
-An `enum` that `implements` an interface maps each value to a codeunit through the `Implementation` property. But an extensible enum can carry values that set no `Implementation` β values added later by an extension, or a value left intentionally blank. Assigning such a value to an interface variable and calling a method on it fails at runtime unless the enum provides a fallback. The enum-level `DefaultImplementation` property names the codeunit used whenever a value has no explicit `Implementation`, so resolution always yields a usable object. LLMs are generally unaware this property exists and leave the gap open.
+An `enum` that `implements` an interface maps each declared value to a codeunit through the `Implementation` property. A declared value, including one supplied by an enum extension, can omit that mapping. Assigning that value to an interface variable then fails at runtime unless the enum provides `DefaultImplementation`. This property is for declared but unmapped values; an ordinal that is no longer declared is a different case covered by `handle-unknown-enum-ordinals-with-unknownvalueimplementation`.
## Best Practice
-On any extensible enum that implements an interface, set `DefaultImplementation = = ;` at the enum level, pointing at a safe implementation that does nothing harmful. Values with their own `Implementation` keep using it; every other value β including ones added later by extensions β resolves to the default instead of failing. For the distinct case of an out-of-range integer that matches no declared value, pair it with `UnknownValueImplementation`. The result is that a consumer can assign any enum value to the interface variable and call through it without a runtime guard.
+On any extensible enum that implements an interface, set `DefaultImplementation = = ;` at the enum level, pointing at a safe implementation. Values with their own `Implementation` keep using it; declared values without one resolve to the default. Do not rely on this property for persisted ordinals that match no declared enum value.
See sample: `set-defaultimplementation-on-enum.good.al`.
diff --git a/microsoft/knowledge/performance/addloadfields-in-report-onpredataitem.bad.al b/microsoft/knowledge/performance/addloadfields-in-report-onpredataitem.bad.al
index 2fd95ac..69b81f3 100644
--- a/microsoft/knowledge/performance/addloadfields-in-report-onpredataitem.bad.al
+++ b/microsoft/knowledge/performance/addloadfields-in-report-onpredataitem.bad.al
@@ -2,13 +2,22 @@ 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) { }
+
+ trigger OnAfterGetRecord()
+ begin
+ // Source Code is not a dataset column, so its first access causes a
+ // just-in-time load and updates the dataitem enumerator.
+ RegisterSourceCode("Source Code");
+ end;
}
}
+
+ local procedure RegisterSourceCode(SourceCode: Code[10])
+ begin
+ end;
}
diff --git a/microsoft/knowledge/performance/addloadfields-in-report-onpredataitem.good.al b/microsoft/knowledge/performance/addloadfields-in-report-onpredataitem.good.al
index 3267418..e235a61 100644
--- a/microsoft/knowledge/performance/addloadfields-in-report-onpredataitem.good.al
+++ b/microsoft/knowledge/performance/addloadfields-in-report-onpredataitem.good.al
@@ -10,8 +10,19 @@ report 50220 "Perf Sample AddLoadFields Good"
trigger OnPreDataItem()
begin
- AddLoadFields("Customer No.", "Posting Date", Amount);
+ // Dataset columns are selected by the report compiler. Source Code is
+ // extra because only trigger code reads it.
+ CustLedgerEntry.AddLoadFields("Source Code");
+ end;
+
+ trigger OnAfterGetRecord()
+ begin
+ RegisterSourceCode("Source Code");
end;
}
}
+
+ local procedure RegisterSourceCode(SourceCode: Code[10])
+ begin
+ end;
}
diff --git a/microsoft/knowledge/performance/addloadfields-in-report-onpredataitem.md b/microsoft/knowledge/performance/addloadfields-in-report-onpredataitem.md
index aa811ce..683a8a0 100644
--- a/microsoft/knowledge/performance/addloadfields-in-report-onpredataitem.md
+++ b/microsoft/knowledge/performance/addloadfields-in-report-onpredataitem.md
@@ -7,20 +7,20 @@ countries: [w1]
application-area: [all]
---
-# In reports, declare the fields the layout needs with AddLoadFields
+# Add trigger-only report fields in OnPreDataItem
## 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.
+Report dataitem field selection is calculated at compile time and once per dataitem type during execution. Fields referenced by dataset columns are selected automatically; fields used only in triggers are not. Use `AddLoadFields` in `OnPreDataItem` to supplement the automatic selection with normal fields that trigger code needs.
## 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.
+When a dataitem trigger needs an extra field, add that field in `OnPreDataItem` before iteration starts. This supplements the compiler-selected fields and avoids the first just-in-time load and enumerator update when the trigger reads the extra field.
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.
+Listing every dataset column in `AddLoadFields`, or omitting a known trigger-only field because the dataset already uses other fields. The former is redundant; the latter causes a just-in-time load on first access and can cause repeated loads when the record is copied or passed by value.
See sample: `addloadfields-in-report-onpredataitem.bad.al`.
diff --git a/microsoft/knowledge/performance/avoid-cloning-records-before-modify-delete-in-loops.bad.al b/microsoft/knowledge/performance/avoid-cloning-records-before-modify-delete-in-loops.bad.al
new file mode 100644
index 0000000..0a70e85
--- /dev/null
+++ b/microsoft/knowledge/performance/avoid-cloning-records-before-modify-delete-in-loops.bad.al
@@ -0,0 +1,19 @@
+codeunit 50493 "Perf Record Clone Bad"
+{
+ procedure IncreaseCustomerCreditLimits(Percent: Decimal)
+ var
+ Customer: Record Customer;
+ CustomerCopy: Record Customer;
+ begin
+ Customer.SetLoadFields("Credit Limit (LCY)");
+ Customer.SetFilter("Credit Limit (LCY)", '>0');
+ if Customer.FindSet(true) then
+ repeat
+ CustomerCopy.Copy(Customer);
+ CustomerCopy.Validate(
+ "Credit Limit (LCY)",
+ Round(CustomerCopy."Credit Limit (LCY)" * (1 + Percent / 100)));
+ CustomerCopy.Modify(true);
+ until Customer.Next() = 0;
+ end;
+}
diff --git a/microsoft/knowledge/performance/avoid-cloning-records-before-modify-delete-in-loops.good.al b/microsoft/knowledge/performance/avoid-cloning-records-before-modify-delete-in-loops.good.al
new file mode 100644
index 0000000..84bfda4
--- /dev/null
+++ b/microsoft/knowledge/performance/avoid-cloning-records-before-modify-delete-in-loops.good.al
@@ -0,0 +1,17 @@
+codeunit 50492 "Perf Record Clone Good"
+{
+ procedure IncreaseCustomerCreditLimits(Percent: Decimal)
+ var
+ Customer: Record Customer;
+ begin
+ Customer.SetLoadFields("Credit Limit (LCY)");
+ Customer.SetFilter("Credit Limit (LCY)", '>0');
+ if Customer.FindSet(true) then
+ repeat
+ Customer.Validate(
+ "Credit Limit (LCY)",
+ Round(Customer."Credit Limit (LCY)" * (1 + Percent / 100)));
+ Customer.Modify(true);
+ until Customer.Next() = 0;
+ end;
+}
diff --git a/microsoft/knowledge/performance/avoid-cloning-records-before-modify-delete-in-loops.md b/microsoft/knowledge/performance/avoid-cloning-records-before-modify-delete-in-loops.md
new file mode 100644
index 0000000..66ee684
--- /dev/null
+++ b/microsoft/knowledge/performance/avoid-cloning-records-before-modify-delete-in-loops.md
@@ -0,0 +1,26 @@
+---
+bc-version: [all]
+domain: performance
+keywords: [clone, clone-before-write, copy, gettable, by-value, copied-record, writing-helper]
+technologies: [al]
+countries: [w1]
+application-area: [all]
+---
+
+# Avoid cloning records before Modify or Delete in loops
+
+## Description
+
+Microsoft's [AL database-method performance guidance](https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/administration/optimize-sql-al-database-methods-and-performance-on-server#insert-modify-delete-and-locktable) states that cloning an iterated record before `Modify` or `Delete` restarts the SQL `SELECT` and issues an extra SQL statement for every row. The runtime treats `Record.Copy`, `RecordRef.GetTable`, and passing a record by value to a writing helper as clones in this situation.
+
+## Best Practice
+
+Use `FindSet(true)` when the loop writes the traversed rows, and call `Modify` or `Delete` on that iterating record variable. If generic code is required, open and iterate the `RecordRef` directly instead of calling `GetTable` for each typed record. Keep a per-row loop when validation or row-specific behavior is required; this rule does not imply that `ModifyAll` or `DeleteAll` is equivalent.
+
+See sample: `avoid-cloning-records-before-modify-delete-in-loops.good.al`.
+
+## Anti Pattern
+
+Inside an active traversal, copy the current row, convert it with `RecordRef.GetTable`, or pass it without `var` to a helper, then call `Modify` or `Delete` on that clone. Do not flag read-only snapshots, temporary records, or copies used to write a different target table; the documented extra-statement concern is clone-before-write on the traversed table.
+
+See sample: `avoid-cloning-records-before-modify-delete-in-loops.bad.al`.
diff --git a/microsoft/knowledge/performance/avoid-commit-inside-loops.good.al b/microsoft/knowledge/performance/avoid-commit-inside-loops.good.al
index afd57a2..2ffa386 100644
--- a/microsoft/knowledge/performance/avoid-commit-inside-loops.good.al
+++ b/microsoft/knowledge/performance/avoid-commit-inside-loops.good.al
@@ -1,21 +1,60 @@
+query 50127 "Perf Customer Chunk"
+{
+ QueryType = Normal;
+ OrderBy = ascending(CustomerNo);
+
+ elements
+ {
+ dataitem(Customer; Customer)
+ {
+ column(CustomerNo; "No.") { }
+ }
+ }
+}
+
codeunit 50128 "Perf Sample CommitInLoop Good"
{
procedure NormalizeCustomerNames()
var
- Customer: Record Customer;
- RowsInChunk: Integer;
- ChunkSize: Integer;
+ LastCustomerNo: Code[20];
begin
- ChunkSize := 500;
- if Customer.FindSet(true) then
+ // The outer loop owns checkpoints; the per-row loop contains no Commit.
+ while NormalizeNextChunk(LastCustomerNo) do
+ Commit();
+ end;
+
+ local procedure NormalizeNextChunk(var LastCustomerNo: Code[20]): Boolean
+ var
+ Customer: Record Customer;
+ TempCustomer: Record Customer temporary;
+ CustomerChunk: Query "Perf Customer Chunk";
+ LastChunkCustomerNo: Code[20];
+ begin
+ CustomerChunk.TopNumberOfRows(500);
+ if LastCustomerNo <> '' then
+ CustomerChunk.SetFilter(CustomerNo, '>%1', LastCustomerNo);
+ CustomerChunk.Open();
+ while CustomerChunk.Read() do begin
+ TempCustomer.Init();
+ TempCustomer."No." := CustomerChunk.CustomerNo;
+ TempCustomer.Insert();
+ LastChunkCustomerNo := CustomerChunk.CustomerNo;
+ end;
+ CustomerChunk.Close();
+
+ if TempCustomer.IsEmpty() then
+ exit(false);
+
+ Customer.LockTable();
+ if TempCustomer.FindSet() then
repeat
- Customer.Name := UpperCase(Customer.Name);
- Customer.Modify();
- RowsInChunk += 1;
- if RowsInChunk >= ChunkSize then begin
- Commit();
- RowsInChunk := 0;
+ if Customer.Get(TempCustomer."No.") then begin
+ Customer.Name := UpperCase(Customer.Name);
+ Customer.Modify();
end;
- until Customer.Next() = 0;
+ until TempCustomer.Next() = 0;
+
+ LastCustomerNo := LastChunkCustomerNo;
+ exit(true);
end;
}
diff --git a/microsoft/knowledge/performance/avoid-commit-inside-loops.md b/microsoft/knowledge/performance/avoid-commit-inside-loops.md
index 98e0c38..13f483a 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: [all]
domain: performance
-keywords: [commit, loop, transaction, lock, checkpoint, codeunit-run]
+keywords: [commit, commit-in-loop, per-row-commit, checkpoint, bounded-checkpoint, watermark, topnumberofrows]
technologies: [al]
countries: [w1]
application-area: [all]
@@ -13,17 +13,16 @@ application-area: [all]
## Description
-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.
+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 select an exact list of at most N keys and process only those 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. Wrapping each chunk in `Codeunit.Run` gives the same effect with native rollback on failure β see `codeunit-run-as-atomic-sub-operation.md`.
+If the batch is large enough that a single transaction is untenable, use an ordered primary-key watermark and retrieve a bounded next-N key list. `FindSet` is optimized for reading the complete filtered set and isn't implemented as `TOP X`, so calling it over the remaining tail and breaking after N rows does not bound retrieval. The sample uses a query capped by [`TopNumberOfRows`](https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/methods-auto/query/queryinstance-topnumberofrows-method) to fill a temporary key buffer, then takes update locks and modifies only those exact keys. It does not reconstruct an inclusive first-to-last range that concurrent inserts could expand. Commit after the bounded inner loop returns and persist its last selected key as the next watermark. Use a stable key and define how a later run handles records inserted at or below an already committed watermark. A `Codeunit.Run` boundary can also own a chunk when its implicit commit and error behavior fit the caller β see `codeunit-run-as-atomic-sub-operation.md`.
See sample: `avoid-commit-inside-loops.good.al`.
## Anti Pattern
-Placing Commit inside `repeat ... until Next() = 0` is almost always a mistake: it is unusual for the correctness of the operation to depend on per-row commits, and the cost of starting a new transaction on every row dominates the work.
+Placing Commit inside `repeat ... until Next() = 0` is almost always a mistake: it is unusual for the correctness of the operation to depend on per-row commits, and the cost of starting a new transaction on every row dominates the work. A capped query that discovers only an upper key and then re-reads an inclusive key range is not exact batching either; concurrent inserts inside that range can enlarge the checkpoint.
See sample: `avoid-commit-inside-loops.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
index 7ff67be..8066218 100644
--- 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
@@ -1,15 +1,17 @@
codeunit 50253 "Perf Sample NPlus1 Bad"
{
- procedure SumStdCost(var BOMLine: Record "BOM Component") TotalCost: Decimal
+ procedure SumStdCost(BOMNo: Code[20]; BOMVersionCode: Code[20]) TotalCost: Decimal
var
+ BOMLine: Record "Production BOM Line";
Item: Record Item;
begin
+ BOMLine.SetRange("Production BOM No.", BOMNo);
+ BOMLine.SetRange("Version Code", BOMVersionCode);
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";
+ 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.good.al b/microsoft/knowledge/performance/avoid-get-inside-loop-on-large-table.good.al
index 2bdbf65..dbd98d8 100644
--- 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
@@ -1,15 +1,38 @@
-codeunit 50252 "Perf Sample NPlus1 Good"
+query 50252 "Perf Sample BOM Cost"
{
- procedure SumStdCost(var BOMLine: Record "BOM Component") TotalCost: Decimal
+ QueryType = Normal;
+
+ elements
+ {
+ dataitem(ProductionBOMLine; "Production BOM Line")
+ {
+ column(ProductionBOMNo; "Production BOM No.") { }
+ column(VersionCode; "Version Code") { }
+ column(QuantityPer; "Quantity per") { }
+
+ dataitem(Item; Item)
+ {
+ DataItemLink = "No." = ProductionBOMLine."No.";
+ DataItemTableFilter = "Costing Method" = const(Standard);
+ SqlJoinType = InnerJoin;
+
+ column(StandardCost; "Standard Cost") { }
+ }
+ }
+ }
+}
+
+codeunit 50254 "Perf Sample NPlus1 Good"
+{
+ procedure SumStdCost(BOMNo: Code[20]; BOMVersionCode: Code[20]) TotalCost: Decimal
var
- Item: Record Item;
+ BOMCost: Query "Perf Sample BOM Cost";
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;
+ BOMCost.SetRange(ProductionBOMNo, BOMNo);
+ BOMCost.SetRange(VersionCode, BOMVersionCode);
+ BOMCost.Open();
+ while BOMCost.Read() do
+ TotalCost += BOMCost.StandardCost * BOMCost.QuantityPer;
+ BOMCost.Close();
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
index 2908d3f..f77fbfe 100644
--- a/microsoft/knowledge/performance/avoid-get-inside-loop-on-large-table.md
+++ b/microsoft/knowledge/performance/avoid-get-inside-loop-on-large-table.md
@@ -11,16 +11,16 @@ application-area: [all]
## 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.
+A `Get` or `FindFirst` against another persistent table inside a loop can produce an N+1 access pattern: one outer query followed by repeated inner lookups. Server and primary-key caches can satisfy some `Get` calls, so a source-level `Get` is not proof of one SQL round-trip. The concern is an unbounded loop whose lookup keys are not known to repeat or remain cached.
## 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.
+Use a query object to join the outer and inner tables when the relationship and filters can be expressed as one query. If keys repeat, a dictionary cache can reduce lookups to one per distinct key. `SetLoadFields` can reduce the columns transferred by unavoidable inner reads, but it does not eliminate the N+1 shape and must not be presented as doing so.
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.
+Iterating production BOM lines and calling `Item.Get(BOMLine."No.")` for each line when the same result can be produced by a query joining Production BOM Line to Item. Partial loading alone is only a payload mitigation for this pattern.
See sample: `avoid-get-inside-loop-on-large-table.bad.al`.
diff --git a/community/knowledge/performance/avoid-growing-globals-in-singleinstance-subscribers.bad.al b/microsoft/knowledge/performance/avoid-growing-globals-in-singleinstance-subscribers.bad.al
similarity index 100%
rename from community/knowledge/performance/avoid-growing-globals-in-singleinstance-subscribers.bad.al
rename to microsoft/knowledge/performance/avoid-growing-globals-in-singleinstance-subscribers.bad.al
diff --git a/community/knowledge/performance/avoid-growing-globals-in-singleinstance-subscribers.good.al b/microsoft/knowledge/performance/avoid-growing-globals-in-singleinstance-subscribers.good.al
similarity index 100%
rename from community/knowledge/performance/avoid-growing-globals-in-singleinstance-subscribers.good.al
rename to microsoft/knowledge/performance/avoid-growing-globals-in-singleinstance-subscribers.good.al
diff --git a/community/knowledge/performance/avoid-growing-globals-in-singleinstance-subscribers.md b/microsoft/knowledge/performance/avoid-growing-globals-in-singleinstance-subscribers.md
similarity index 100%
rename from community/knowledge/performance/avoid-growing-globals-in-singleinstance-subscribers.md
rename to microsoft/knowledge/performance/avoid-growing-globals-in-singleinstance-subscribers.md
diff --git a/community/knowledge/performance/choose-maintainsiftindex-by-read-write-ratio.good.al b/microsoft/knowledge/performance/choose-maintainsiftindex-by-read-write-ratio.good.al
similarity index 100%
rename from community/knowledge/performance/choose-maintainsiftindex-by-read-write-ratio.good.al
rename to microsoft/knowledge/performance/choose-maintainsiftindex-by-read-write-ratio.good.al
diff --git a/microsoft/knowledge/performance/choose-maintainsiftindex-by-read-write-ratio.md b/microsoft/knowledge/performance/choose-maintainsiftindex-by-read-write-ratio.md
new file mode 100644
index 0000000..3261a2c
--- /dev/null
+++ b/microsoft/knowledge/performance/choose-maintainsiftindex-by-read-write-ratio.md
@@ -0,0 +1,26 @@
+---
+bc-version: [all]
+domain: performance
+keywords: [maintainsiftindex, sift, calcsums, flowfield, write-cost]
+technologies: [al]
+countries: [w1]
+application-area: [all]
+---
+
+# Choose MaintainSIFTIndex by read-write ratio
+
+> Contributions welcome β open a PR to refine or extend this article.
+
+## Description
+
+`MaintainSIFTIndex` on a key decides whether SQL Server maintains the SIFT indexed view as underlying rows change. With `Yes`, writes that affect the key or sum fields also maintain the indexed aggregate. With `No`, that SIFT indexed view is not maintained, so a compatible `CalcSums` or FlowField calculation is computed from the base table instead and may require scanning many rows. There is no deferred "first read rebuild" of the SIFT structure.
+
+## Best Practice
+
+Measure aggregate-read latency and write cost under realistic filters and volumes. Keep `MaintainSIFTIndex = true` when the maintained aggregate materially benefits frequent `CalcSums` or FlowField reads. Consider `false` when writes dominate and the less-frequent aggregate reads can tolerate calculation from the base table.
+
+See sample: `choose-maintainsiftindex-by-read-write-ratio.good.al`.
+
+## Anti Pattern
+
+Leaving `MaintainSIFTIndex = Yes` on every key by reflex or convenience. On write-heavy tables the cumulative cost turns every INSERT or MODIFY into several additional aggregate updates, and the impact compounds in batch imports and posting routines β often without any code-review signal that the property is the cause.
diff --git a/microsoft/knowledge/performance/load-common-fields-before-branching-on-case.bad.al b/microsoft/knowledge/performance/load-common-fields-before-branching-on-case.bad.al
new file mode 100644
index 0000000..dae063c
--- /dev/null
+++ b/microsoft/knowledge/performance/load-common-fields-before-branching-on-case.bad.al
@@ -0,0 +1,30 @@
+codeunit 50100 "Sales Document Processor"
+{
+ procedure DescribeDocument(DocumentType: Enum "Sales Document Type"; DocumentNo: Code[20]): Text
+ var
+ SalesHeader: Record "Sales Header";
+ begin
+ SalesHeader.SetLoadFields(
+ "Sell-to Customer No.",
+ "Order Date", "Shipment Date", "Completely Shipped",
+ "Posting Date", "Due Date", "Payment Terms Code");
+ SalesHeader.Get(DocumentType, DocumentNo);
+
+ case DocumentType of
+ DocumentType::Order:
+ exit(DescribeOrder(SalesHeader));
+ DocumentType::Invoice:
+ exit(DescribeInvoice(SalesHeader));
+ end;
+ end;
+
+ local procedure DescribeOrder(SalesHeader: Record "Sales Header"): Text
+ begin
+ exit(StrSubstNo('%1|%2|%3|%4', SalesHeader."Sell-to Customer No.", SalesHeader."Order Date", SalesHeader."Shipment Date", SalesHeader."Completely Shipped"));
+ end;
+
+ local procedure DescribeInvoice(SalesHeader: Record "Sales Header"): Text
+ begin
+ exit(StrSubstNo('%1|%2|%3|%4', SalesHeader."Sell-to Customer No.", SalesHeader."Posting Date", SalesHeader."Due Date", SalesHeader."Payment Terms Code"));
+ end;
+}
diff --git a/microsoft/knowledge/performance/load-common-fields-before-branching-on-case.good.al b/microsoft/knowledge/performance/load-common-fields-before-branching-on-case.good.al
new file mode 100644
index 0000000..aeb0cf7
--- /dev/null
+++ b/microsoft/knowledge/performance/load-common-fields-before-branching-on-case.good.al
@@ -0,0 +1,34 @@
+codeunit 50100 "Sales Document Processor"
+{
+ procedure DescribeDocument(DocumentType: Enum "Sales Document Type"; DocumentNo: Code[20]): Text
+ var
+ SalesHeader: Record "Sales Header";
+ begin
+ SalesHeader.SetLoadFields("Sell-to Customer No.");
+
+ case DocumentType of
+ DocumentType::Order:
+ begin
+ SalesHeader.AddLoadFields("Order Date", "Shipment Date", "Completely Shipped");
+ SalesHeader.Get(DocumentType, DocumentNo);
+ exit(DescribeOrder(SalesHeader));
+ end;
+ DocumentType::Invoice:
+ begin
+ SalesHeader.AddLoadFields("Posting Date", "Due Date", "Payment Terms Code");
+ SalesHeader.Get(DocumentType, DocumentNo);
+ exit(DescribeInvoice(SalesHeader));
+ end;
+ end;
+ end;
+
+ local procedure DescribeOrder(SalesHeader: Record "Sales Header"): Text
+ begin
+ exit(StrSubstNo('%1|%2|%3|%4', SalesHeader."Sell-to Customer No.", SalesHeader."Order Date", SalesHeader."Shipment Date", SalesHeader."Completely Shipped"));
+ end;
+
+ local procedure DescribeInvoice(SalesHeader: Record "Sales Header"): Text
+ begin
+ exit(StrSubstNo('%1|%2|%3|%4', SalesHeader."Sell-to Customer No.", SalesHeader."Posting Date", SalesHeader."Due Date", SalesHeader."Payment Terms Code"));
+ end;
+}
diff --git a/microsoft/knowledge/performance/load-common-fields-before-branching-on-case.md b/microsoft/knowledge/performance/load-common-fields-before-branching-on-case.md
new file mode 100644
index 0000000..257b0fb
--- /dev/null
+++ b/microsoft/knowledge/performance/load-common-fields-before-branching-on-case.md
@@ -0,0 +1,28 @@
+---
+bc-version: [all]
+domain: performance
+keywords: [setloadfields, case, conditional, branch, field-loading]
+technologies: [al]
+countries: [w1]
+application-area: [all]
+---
+
+# Load common fields before branching on case
+
+> Contributions welcome β open a PR to refine or extend this article.
+
+## Description
+
+When a known input determines which fields a subsequent record read will use, a single `SetLoadFields` containing every branch's fields loads unnecessary columns. Build the selection before `Get`, `FindFirst`, or `FindSet`: use `SetLoadFields` for fields common to every branch, then `AddLoadFields` for the selected branch. `SetLoadFields` replaces the current selection, while `AddLoadFields` preserves it.
+
+## Best Practice
+
+Call `SetLoadFields` with the common fields. In each branch, call `AddLoadFields` with that branch's normal fields and then perform the record read. This applies only when the discriminator is known before the read; branching on a field from an already-loaded row is too late to tailor that row's initial SQL projection.
+
+See sample: `load-common-fields-before-branching-on-case.good.al`.
+
+## Anti Pattern
+
+A single top-level `SetLoadFields` enumerating every branch's fields, or a branch-local `SetLoadFields` that accidentally discards the common selection. Both make the declared load plan differ from the fields the selected path actually uses.
+
+See sample: `load-common-fields-before-branching-on-case.bad.al`.
diff --git a/community/knowledge/performance/load-only-primary-key-fields-for-reference-work.bad.al b/microsoft/knowledge/performance/load-only-primary-key-fields-for-reference-work.bad.al
similarity index 100%
rename from community/knowledge/performance/load-only-primary-key-fields-for-reference-work.bad.al
rename to microsoft/knowledge/performance/load-only-primary-key-fields-for-reference-work.bad.al
diff --git a/community/knowledge/performance/load-only-primary-key-fields-for-reference-work.good.al b/microsoft/knowledge/performance/load-only-primary-key-fields-for-reference-work.good.al
similarity index 100%
rename from community/knowledge/performance/load-only-primary-key-fields-for-reference-work.good.al
rename to microsoft/knowledge/performance/load-only-primary-key-fields-for-reference-work.good.al
diff --git a/community/knowledge/performance/load-only-primary-key-fields-for-reference-work.md b/microsoft/knowledge/performance/load-only-primary-key-fields-for-reference-work.md
similarity index 100%
rename from community/knowledge/performance/load-only-primary-key-fields-for-reference-work.md
rename to microsoft/knowledge/performance/load-only-primary-key-fields-for-reference-work.md
diff --git a/community/knowledge/performance/order-case-branches-by-frequency.bad.al b/microsoft/knowledge/performance/order-case-branches-by-frequency.bad.al
similarity index 100%
rename from community/knowledge/performance/order-case-branches-by-frequency.bad.al
rename to microsoft/knowledge/performance/order-case-branches-by-frequency.bad.al
diff --git a/community/knowledge/performance/order-case-branches-by-frequency.good.al b/microsoft/knowledge/performance/order-case-branches-by-frequency.good.al
similarity index 78%
rename from community/knowledge/performance/order-case-branches-by-frequency.good.al
rename to microsoft/knowledge/performance/order-case-branches-by-frequency.good.al
index c650375..707e696 100644
--- a/community/knowledge/performance/order-case-branches-by-frequency.good.al
+++ b/microsoft/knowledge/performance/order-case-branches-by-frequency.good.al
@@ -2,8 +2,7 @@ codeunit 50100 "Document Router"
{
procedure Route(SalesHeader: Record "Sales Header")
begin
- // In this deployment Orders are ~85% of posting calls, Invoices ~12%,
- // and the rest are edge cases. The hot branch goes first.
+ // Profiling shows Orders are the common case, so that branch goes first.
case SalesHeader."Document Type" of
SalesHeader."Document Type"::Order:
RouteOrder(SalesHeader);
@@ -11,15 +10,16 @@ codeunit 50100 "Document Router"
RouteInvoice(SalesHeader);
SalesHeader."Document Type"::"Credit Memo":
RouteCreditMemo(SalesHeader);
+ SalesHeader."Document Type"::Quote:
+ RouteQuote(SalesHeader);
SalesHeader."Document Type"::"Return Order":
RouteReturnOrder(SalesHeader);
- else
- Error('Unexpected document type %1', SalesHeader."Document Type");
end;
end;
local procedure RouteOrder(SalesHeader: Record "Sales Header") begin end;
local procedure RouteInvoice(SalesHeader: Record "Sales Header") begin end;
+ local procedure RouteQuote(SalesHeader: Record "Sales Header") begin end;
local procedure RouteCreditMemo(SalesHeader: Record "Sales Header") begin end;
local procedure RouteReturnOrder(SalesHeader: Record "Sales Header") begin end;
}
diff --git a/microsoft/knowledge/performance/order-case-branches-by-frequency.md b/microsoft/knowledge/performance/order-case-branches-by-frequency.md
new file mode 100644
index 0000000..1634475
--- /dev/null
+++ b/microsoft/knowledge/performance/order-case-branches-by-frequency.md
@@ -0,0 +1,28 @@
+---
+bc-version: [all]
+domain: performance
+keywords: [case, branch, frequency, control-flow, hot-path]
+technologies: [al]
+countries: [w1]
+application-area: [all]
+---
+
+# Order case branches by frequency
+
+> Contributions welcome β open a PR to refine or extend this article.
+
+## Description
+
+AL documentation does not guarantee that a `case` statement uses a linear comparison strategy, so branch frequency alone is not proof of a performance issue. Reordering is justified only when profiling on the target runtime shows that a large, heavily skewed `case` is a material hot path. It is not a default review finding.
+
+## Best Practice
+
+After profiling confirms the comparison path matters and the runtime frequency is known, list common branches first without changing the set of handled values, fallback behavior, or branch bodies.
+
+See sample: `order-case-branches-by-frequency.good.al`.
+
+## Anti Pattern
+
+Reordering branches based on assumed frequency without profiling, or changing an `else` arm or handled value while making the optimization. The good and bad forms must differ only in branch order.
+
+See sample: `order-case-branches-by-frequency.bad.al`.
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
index 458d098..fea22d8 100644
--- a/microsoft/knowledge/performance/prefer-dictionary-over-temporary-table-for-lookups.md
+++ b/microsoft/knowledge/performance/prefer-dictionary-over-temporary-table-for-lookups.md
@@ -11,12 +11,12 @@ application-area: [all]
## 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.
+An AL `Dictionary` directly models an unordered unique key-to-value collection. A temporary table models records and supports keys, filters, validation, and ordered iteration in Business Central Server memory. For a pure lookup map, the dictionary avoids repeatedly configuring and searching a temporary record and makes the intended access pattern explicit.
## 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.
+Use `Dictionary of [Key, Value]` when the operation is add-or-replace, contains-key, and get-value by one supported key type. Use a temporary table when the value is a record, or when the code needs filters, ordered iteration, multiple fields, multiple keys, or table behavior. Both structures consume service-tier memory and still need volume analysis.
## 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.
+A temporary record used only through `SetRange(KeyField, X); FindFirst()` to retrieve one scalar value, with no record semantics that justify the table. The opposite mistake is replacing a temporary table that needs filtering or ordered iteration with a dictionary.
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
index 0b8c21d..1626d02 100644
--- a/microsoft/knowledge/performance/prefer-modifyall-over-per-row-modify.bad.al
+++ b/microsoft/knowledge/performance/prefer-modifyall-over-per-row-modify.bad.al
@@ -1,15 +1,29 @@
+table 50243 "Perf Import Staging Entry"
+{
+ fields
+ {
+ field(1; "Entry No."; Integer) { }
+ field(2; "Batch ID"; Guid) { }
+ field(3; Processed; Boolean) { }
+ }
+
+ keys
+ {
+ key(PK; "Entry No.") { Clustered = true; }
+ }
+}
+
codeunit 50243 "Perf Sample ModifyAll Bad"
{
- procedure ApplyPriceUpdate(NewPrice: Decimal)
+ procedure MarkBatchProcessed(BatchId: Guid)
var
- SalesLine: Record "Sales Line";
+ StagingEntry: Record "Perf Import Staging Entry";
begin
- SalesLine.SetRange(Type, SalesLine.Type::Item);
- // N writes when one ModifyAll would do.
- if SalesLine.FindSet() then
+ StagingEntry.SetRange("Batch ID", BatchId);
+ if StagingEntry.FindSet(true) then
repeat
- SalesLine.Validate("Unit Price", NewPrice);
- SalesLine.Modify(true);
- until SalesLine.Next() = 0;
+ StagingEntry.Processed := true;
+ StagingEntry.Modify(false);
+ until StagingEntry.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
index c33d9c0..9a3ad4c 100644
--- a/microsoft/knowledge/performance/prefer-modifyall-over-per-row-modify.good.al
+++ b/microsoft/knowledge/performance/prefer-modifyall-over-per-row-modify.good.al
@@ -1,20 +1,26 @@
+table 50242 "Perf Import Staging Entry"
+{
+ fields
+ {
+ field(1; "Entry No."; Integer) { }
+ field(2; "Batch ID"; Guid) { }
+ field(3; Processed; Boolean) { }
+ }
+
+ keys
+ {
+ key(PK; "Entry No.") { Clustered = true; }
+ }
+}
+
codeunit 50242 "Perf Sample ModifyAll Good"
{
- procedure ApplyPriceUpdate(NewPrice: Decimal)
+ procedure MarkBatchProcessed(BatchId: Guid)
var
- SalesLine: Record "Sales Line";
+ StagingEntry: Record "Perf Import Staging Entry";
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);
+ StagingEntry.SetRange("Batch ID", BatchId);
+ // Processed has no OnValidate logic, and the equivalent loop uses Modify(false).
+ StagingEntry.ModifyAll(Processed, true, 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
index 73a095c..ae83968 100644
--- a/microsoft/knowledge/performance/prefer-modifyall-over-per-row-modify.md
+++ b/microsoft/knowledge/performance/prefer-modifyall-over-per-row-modify.md
@@ -7,20 +7,20 @@ countries: [w1]
application-area: [all]
---
-# Use ModifyAll / DeleteAll instead of per-row Modify / Delete in a loop
+# Use ModifyAll only for equivalent bulk assignments
## 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.
+`ModifyAll` assigns one value to one field across the filtered set. It does not run the field's `OnValidate` trigger. Its optional `RunTrigger` parameter controls the table `OnModify` trigger, not field validation. Replacing a loop is therefore correct only when direct assignment is semantically equivalent for every row.
## 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.
+Use `ModifyAll` when the loop directly assigns the same value, does not call `Validate`, needs no per-row calculation, and does not depend on `OnModify` unless the equivalent `RunTrigger` value is supplied. Check whether table-extension triggers, event subscribers, global triggers, or media fields force row-by-row fallback (see `triggers-and-media-field-regress-modifyall.md`).
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.
+A loop that only assigns a constant and calls `Modify(false)` on a field with no validation side effects. Conversely, replacing `Validate(Field, Value); Modify(true)` with `ModifyAll(Field, Value)` is also an anti-pattern because it silently drops field validation and may drop table-trigger behavior.
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
index c23886c..7827a1f 100644
--- a/microsoft/knowledge/performance/prefer-readisolation-over-locktable-for-reads.bad.al
+++ b/microsoft/knowledge/performance/prefer-readisolation-over-locktable-for-reads.bad.al
@@ -1,13 +1,13 @@
codeunit 50233 "Perf Sample ReadIso Bad"
{
- procedure GetOrCreate(var AgentStatus: Record "Agent Status")
+ procedure IsCustomerBlocked(CustomerNo: Code[20]): Boolean
+ var
+ Customer: Record Customer;
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;
+ Customer.LockTable();
+ if not Customer.Get(CustomerNo) then
+ exit(false);
+
+ exit(Customer.Blocked <> Customer.Blocked::" ");
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
index be5cd55..280c3d2 100644
--- a/microsoft/knowledge/performance/prefer-readisolation-over-locktable-for-reads.good.al
+++ b/microsoft/knowledge/performance/prefer-readisolation-over-locktable-for-reads.good.al
@@ -1,11 +1,13 @@
codeunit 50232 "Perf Sample ReadIso Good"
{
- procedure GetOrCreate(var AgentStatus: Record "Agent Status")
+ procedure IsCustomerBlocked(CustomerNo: Code[20]): Boolean
+ var
+ Customer: Record Customer;
begin
- AgentStatus.ReadIsolation := IsolationLevel::ReadCommitted;
- if not AgentStatus.Get() then begin
- AgentStatus.Init();
- AgentStatus.Insert();
- end;
+ Customer.ReadIsolation := IsolationLevel::ReadCommitted;
+ if not Customer.Get(CustomerNo) then
+ exit(false);
+
+ exit(Customer.Blocked <> Customer.Blocked::" ");
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
index c2f888b..f798636 100644
--- a/microsoft/knowledge/performance/prefer-readisolation-over-locktable-for-reads.md
+++ b/microsoft/knowledge/performance/prefer-readisolation-over-locktable-for-reads.md
@@ -11,16 +11,16 @@ application-area: [all]
## 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.
+Without read scale-out, `LockTable` causes subsequent reads of that table in the transaction to use `UPDLOCK`. With read scale-out, those reads use `REPEATABLEREAD` on the replica instead. `ReadIsolation` selects an isolation level for one record instance. A helper that only reads should not broaden locking for the table merely to request committed data.
## 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).
+For a read-only operation that specifically requires committed data, set `Rec.ReadIsolation := IsolationLevel::ReadCommitted` immediately before the read. If the default isolation is sufficient, set neither property. `ReadCommitted` can still block behind writers and does not guarantee that repeated reads stay unchanged; use the isolation level required by the operation. Reserve update locks for read-before-write logic, not read-only helpers.
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.
+`Rec.LockTable();` at the top of a helper that only reads, perhaps to "make sure the read is consistent". It takes stronger isolation than the helper needs and changes later reads of that table in the surrounding transaction or read-scale-out session.
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
deleted file mode 100644
index f290448..0000000
--- a/microsoft/knowledge/performance/production-scale-tables-warrant-extra-analysis.md
+++ /dev/null
@@ -1,22 +0,0 @@
----
-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/singleton-setup-tables-need-no-access-optimization.md b/microsoft/knowledge/performance/singleton-setup-tables-need-no-access-optimization.md
index 6b32de1..796c918 100644
--- a/microsoft/knowledge/performance/singleton-setup-tables-need-no-access-optimization.md
+++ b/microsoft/knowledge/performance/singleton-setup-tables-need-no-access-optimization.md
@@ -7,16 +7,16 @@ countries: [w1]
application-area: [all]
---
-# Singleton setup tables hold one row; access-pattern optimization is wasted
+# Enforced singleton setup tables need no access optimization
## 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.
+An access-pattern exemption is valid only for a table whose schema and write paths enforce at most one row for the relevant scope. A conventional blank primary key, a parameterless `Get()`, or a table name ending in `Setup` does not enforce that invariant; another primary-key value can still create another row unless insertion logic prevents it.
## 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`).
+Exempt a setup read only after confirming that noncanonical keys are rejected and every supported creation path preserves the singleton. Otherwise apply ordinary access-pattern analysis, even when existing application code normally uses one blank-key record.
## 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.
+Treating every `*Setup` table or parameterless `Get()` as proof of bounded cardinality without checking the primary key and insertion logic.
diff --git a/microsoft/knowledge/performance/temporary-tables-have-no-database-cost.md b/microsoft/knowledge/performance/temporary-tables-have-no-database-cost.md
index d799b5f..3fd8072 100644
--- a/microsoft/knowledge/performance/temporary-tables-have-no-database-cost.md
+++ b/microsoft/knowledge/performance/temporary-tables-have-no-database-cost.md
@@ -7,16 +7,16 @@ countries: [w1]
application-area: [all]
---
-# Temporary tables are in-memory; access-pattern rules do not apply
+# Temporary tables avoid SQL I/O, not in-memory work
## 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.
+A temporary table stores its rows in Business Central Server memory instead of a physical SQL table. Its reads and writes therefore do not incur SQL round-trips, locking, or SIFT maintenance. They still allocate memory and execute record filtering, key lookup, sorting, insertion, and iteration in the service tier; those costs grow with the temporary dataset and access pattern.
## 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.
+Do not apply SQL-specific findings such as missing `SetLoadFields`, lock contention, or N+1 database round-trips to a temporary record. Still assess memory volume and repeated scans or lookups. For a pure key-to-value collection, consider an AL `Dictionary`; keep a temporary table when record fields, keys, filtering, or ordered iteration are required.
## 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.
+Claiming that every temporary-table access pattern is free because no SQL is involved. A nested scan over a large in-memory buffer can still dominate service-tier CPU, while adding `SetLoadFields` to that buffer addresses a database cost that does not exist.
diff --git a/microsoft/knowledge/performance/use-deleteall-for-filtered-bulk-deletion.bad.al b/microsoft/knowledge/performance/use-deleteall-for-filtered-bulk-deletion.bad.al
new file mode 100644
index 0000000..d91d454
--- /dev/null
+++ b/microsoft/knowledge/performance/use-deleteall-for-filtered-bulk-deletion.bad.al
@@ -0,0 +1,29 @@
+table 50100 "Perf Import Buffer"
+{
+ fields
+ {
+ field(1; "Entry No."; Integer) { }
+ field(2; "Batch ID"; Guid) { }
+ field(3; Payload; Blob) { }
+ }
+
+ keys
+ {
+ key(PK; "Entry No.") { Clustered = true; }
+ key(ByBatch; "Batch ID") { }
+ }
+}
+
+codeunit 50100 "Perf Import Buffer Cleanup"
+{
+ procedure ClearBatch(BatchId: Guid)
+ var
+ ImportBuffer: Record "Perf Import Buffer";
+ begin
+ ImportBuffer.SetRange("Batch ID", BatchId);
+ if ImportBuffer.FindSet() then
+ repeat
+ ImportBuffer.Delete(false);
+ until ImportBuffer.Next() = 0;
+ end;
+}
diff --git a/microsoft/knowledge/performance/use-deleteall-for-filtered-bulk-deletion.good.al b/microsoft/knowledge/performance/use-deleteall-for-filtered-bulk-deletion.good.al
new file mode 100644
index 0000000..738780c
--- /dev/null
+++ b/microsoft/knowledge/performance/use-deleteall-for-filtered-bulk-deletion.good.al
@@ -0,0 +1,28 @@
+table 50100 "Perf Import Buffer"
+{
+ fields
+ {
+ field(1; "Entry No."; Integer) { }
+ field(2; "Batch ID"; Guid) { }
+ field(3; Payload; Blob) { }
+ }
+
+ keys
+ {
+ key(PK; "Entry No.") { Clustered = true; }
+ key(ByBatch; "Batch ID") { }
+ }
+}
+
+codeunit 50100 "Perf Import Buffer Cleanup"
+{
+ procedure ClearBatch(BatchId: Guid)
+ var
+ ImportBuffer: Record "Perf Import Buffer";
+ begin
+ ImportBuffer.SetRange("Batch ID", BatchId);
+ // This staging table has no base delete trigger. Installed extensions and
+ // subscribers must also be checked before assuming the set-based fast path.
+ ImportBuffer.DeleteAll(false);
+ end;
+}
diff --git a/microsoft/knowledge/performance/use-deleteall-for-filtered-bulk-deletion.md b/microsoft/knowledge/performance/use-deleteall-for-filtered-bulk-deletion.md
new file mode 100644
index 0000000..1c80835
--- /dev/null
+++ b/microsoft/knowledge/performance/use-deleteall-for-filtered-bulk-deletion.md
@@ -0,0 +1,28 @@
+---
+bc-version: [all]
+domain: performance
+keywords: [deleteall, bulk-delete, sql, ondelete, trigger-bypass]
+technologies: [al]
+countries: [w1]
+application-area: [all]
+---
+
+# Use DeleteAll for filtered bulk deletion
+
+> Contributions welcome β open a PR to refine or extend this article.
+
+## Description
+
+`DeleteAll(false)` is eligible for a set-based SQL delete with the record variable's filters applied. It is not guaranteed to stay one statement. The base table `OnDelete` trigger is skipped, but table-extension `OnBeforeDelete` and `OnAfterDelete` triggers still run. Extension event subscribers, global delete triggers, and media fields can also require row processing. `DeleteAll(true)` runs the base table `OnDelete` trigger as well and has no performance advantage over `Delete(true)` in a loop.
+
+## Best Practice
+
+Use filtered `DeleteAll(false)` for purpose-built staging or cleanup tables only after verifying that base-table `OnDelete` logic is unnecessary and installed extensions, subscribers, global triggers, and media fields do not add required per-row behavior or regress the bulk path. If deletion requires per-row business logic, keep an explicit triggered operation instead of simulating trigger execution separately.
+
+See sample: `use-deleteall-for-filtered-bulk-deletion.good.al`.
+
+## Anti Pattern
+
+Iterating with `FindSet` + `Delete(false)` to clear a filtered staging batch that has no delete logic. The reverse mistake is assuming `DeleteAll` is always one SQL statement without checking table extensions and subscribers.
+
+See sample: `use-deleteall-for-filtered-bulk-deletion.bad.al`.
diff --git a/microsoft/knowledge/performance/use-setautocalcfields-for-per-row-flowfields.bad.al b/microsoft/knowledge/performance/use-setautocalcfields-for-per-row-flowfields.bad.al
new file mode 100644
index 0000000..86b1842
--- /dev/null
+++ b/microsoft/knowledge/performance/use-setautocalcfields-for-per-row-flowfields.bad.al
@@ -0,0 +1,16 @@
+codeunit 50491 "Perf AutoCalcFields Bad"
+{
+ procedure CollectOverLimitCustomers(var CustomerNos: List of [Code[20]])
+ var
+ Customer: Record Customer;
+ begin
+ Customer.SetLoadFields("Credit Limit (LCY)");
+ Customer.SetFilter("Credit Limit (LCY)", '>0');
+ if Customer.FindSet() then
+ repeat
+ Customer.CalcFields("Balance (LCY)");
+ if Customer."Balance (LCY)" > Customer."Credit Limit (LCY)" then
+ CustomerNos.Add(Customer."No.");
+ until Customer.Next() = 0;
+ end;
+}
diff --git a/microsoft/knowledge/performance/use-setautocalcfields-for-per-row-flowfields.good.al b/microsoft/knowledge/performance/use-setautocalcfields-for-per-row-flowfields.good.al
new file mode 100644
index 0000000..072321c
--- /dev/null
+++ b/microsoft/knowledge/performance/use-setautocalcfields-for-per-row-flowfields.good.al
@@ -0,0 +1,16 @@
+codeunit 50490 "Perf AutoCalcFields Good"
+{
+ procedure CollectOverLimitCustomers(var CustomerNos: List of [Code[20]])
+ var
+ Customer: Record Customer;
+ begin
+ Customer.SetLoadFields("Credit Limit (LCY)");
+ Customer.SetFilter("Credit Limit (LCY)", '>0');
+ Customer.SetAutoCalcFields("Balance (LCY)");
+ if Customer.FindSet() then
+ repeat
+ if Customer."Balance (LCY)" > Customer."Credit Limit (LCY)" then
+ CustomerNos.Add(Customer."No.");
+ until Customer.Next() = 0;
+ end;
+}
diff --git a/microsoft/knowledge/performance/use-setautocalcfields-for-per-row-flowfields.md b/microsoft/knowledge/performance/use-setautocalcfields-for-per-row-flowfields.md
new file mode 100644
index 0000000..0c749ce
--- /dev/null
+++ b/microsoft/knowledge/performance/use-setautocalcfields-for-per-row-flowfields.md
@@ -0,0 +1,26 @@
+---
+bc-version: [all]
+domain: performance
+keywords: [setautocalcfields, calcfields, calcsums, flowfield, loop, per-row]
+technologies: [al]
+countries: [w1]
+application-area: [all]
+---
+
+# Use SetAutoCalcFields when each iterated row needs a FlowField
+
+## Description
+
+`Record.SetAutoCalcFields` has been available since runtime 1.0 and makes the specified FlowFields calculate as records are retrieved. Microsoft's [AL database-method performance guidance](https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/administration/optimize-sql-al-database-methods-and-performance-on-server#setautocalcfields) uses it to remove an explicit `CalcFields` call from every iteration when each row's FlowField drives a branch. This is different from `CalcSums`, which returns a total for the filtered set rather than a value for each row.
+
+## Best Practice
+
+Call `SetAutoCalcFields` before `FindSet` when every returned row needs the same FlowField for a comparison, branch, or per-record action. Use `CalcSums` instead when the required result is one aggregate over the filtered set (see `calcsums-instead-of-calcfields-in-loop.md`).
+
+See sample: `use-setautocalcfields-for-per-row-flowfields.good.al`.
+
+## Anti Pattern
+
+Calling `CalcFields` inside the loop when every iteration reads the same FlowField. Each `CalcFields` request requires a separate SQL statement unless a compatible recent result is cached. Do not replace row-specific decisions with `CalcSums`; an aggregate cannot preserve which rows met the condition.
+
+See sample: `use-setautocalcfields-for-per-row-flowfields.bad.al`.
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
index f19636a..abd6acb 100644
--- a/microsoft/knowledge/performance/use-textbuilder-for-string-concatenation-in-loops.md
+++ b/microsoft/knowledge/performance/use-textbuilder-for-string-concatenation-in-loops.md
@@ -7,16 +7,16 @@ countries: [w1]
application-area: [all]
---
-# Use TextBuilder for many string concatenations, especially inside loops
+# Use AL TextBuilder for repeated text mutation
## 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.
+AL `TextBuilder` is a reference type intended for modifying text without creating a new `Text` value for each change. Microsoft documents it as the performance-oriented AL primitive for concatenating many strings, including loop-built output. `Append` and `AppendLine` build the value, and `ToText` returns the completed text.
## 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.
+When a loop repeatedly appends fragments to one result, use a `TextBuilder` local and convert once after the loop. Keep ordinary `Text` expressions for a fixed, small number of fragments; this rule is about repeated mutation, not every concatenation.
## 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.
+Building an unbounded export or message with `Result += Fragment` on every iteration when AL's `TextBuilder` directly represents the operation.
diff --git a/microsoft/knowledge/privacy/avoid-strsubstno-prebuild-before-error.bad.al b/microsoft/knowledge/privacy/avoid-strsubstno-prebuild-before-error.bad.al
index 08f1702..fd1ded5 100644
--- a/microsoft/knowledge/privacy/avoid-strsubstno-prebuild-before-error.bad.al
+++ b/microsoft/knowledge/privacy/avoid-strsubstno-prebuild-before-error.bad.al
@@ -2,10 +2,16 @@ codeunit 50207 "Privacy Sample StrSubstNo Bad"
{
procedure ReportFailure(var Customer: Record Customer)
var
- ErrorMsg: Text;
+ CustomerInvalidErr: Label 'Customer %1 has invalid data.', Comment = '%1 = Customer No.';
begin
- ErrorMsg := StrSubstNo('Customer %1 (%2) at %3 has invalid data',
- Customer.Name, Customer."E-Mail", Customer.Address);
- Error(ErrorMsg);
+ Error(StrSubstNo(CustomerInvalidErr, Customer."No."));
+ end;
+
+ procedure ReportCombinedFailure()
+ var
+ HeaderErr: Label 'Customer validation failed. ';
+ DetailErr: Label 'Correct the customer card and try again.';
+ begin
+ Error(HeaderErr + DetailErr);
end;
}
diff --git a/microsoft/knowledge/privacy/avoid-strsubstno-prebuild-before-error.md b/microsoft/knowledge/privacy/avoid-strsubstno-prebuild-before-error.md
index 7d4c1e7..5d292fa 100644
--- a/microsoft/knowledge/privacy/avoid-strsubstno-prebuild-before-error.md
+++ b/microsoft/knowledge/privacy/avoid-strsubstno-prebuild-before-error.md
@@ -1,5 +1,5 @@
---
-bc-version: [all]
+bc-version: [20..]
domain: privacy
keywords: [strsubstno, error, telemetry, pii, prebuild, text-variable]
technologies: [al]
@@ -7,20 +7,20 @@ countries: [w1]
application-area: [all]
---
-# Do not pre-build an error string with `StrSubstNo` before calling `Error()`
+# Pass a Label directly as the first Error argument
## 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.
+Error method trace telemetry includes the AL error string only when the first `Error` argument is a `Label` or `TextConst`. Wrapping a label in `StrSubstNo`, or concatenating labels or text, produces a dynamic `Text` first argument. In that case the actual string is not emitted as the telemetry message; the platform emits its generic guidance instead. CodeCop AA0231 flags both shapes because the label identity and data-classification context are lost.
## 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`.
+Declare the complete message as a `Label` or `TextConst` and pass it directly to `Error`, followed by substitution values. The client receives the formatted message while telemetry can retain the static message template without using the dynamic values as its message. See `error-direct-substitution-safe-for-telemetry.md`.
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()`.
+`Error(StrSubstNo(CustomerInvalidErr, Customer."No."))` and `Error(HeaderErr + DetailErr)` both make the first argument dynamic. They reduce error telemetry quality; they do not cause that composed string to be logged verbatim as the telemetry message.
See sample: `avoid-strsubstno-prebuild-before-error.bad.al`.
diff --git a/microsoft/knowledge/privacy/data-classification-required-on-pii-fields.md b/microsoft/knowledge/privacy/data-classification-required-on-pii-fields.md
index 808e103..d3e1e55 100644
--- a/microsoft/knowledge/privacy/data-classification-required-on-pii-fields.md
+++ b/microsoft/knowledge/privacy/data-classification-required-on-pii-fields.md
@@ -11,11 +11,11 @@ application-area: [all]
## 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.
+`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, customer, or organization data. When the property is omitted, AL applies `ToBeClassified` β a placeholder meaning "not yet reviewed", not a safe default. Leaving a field that actually holds PII (an email address, a customer name, an employee code) as `ToBeClassified`, or setting it to `SystemMetadata` ("no user or customer data") to silence the requirement, are both under-classifications and privacy bugs, 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.
+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. A field that identifies an organization rather than a person β a company registration or VAT registration number β is `OrganizationIdentifiableInformation`, and a financial account identifier such as a bank account number or IBAN is `AccountData`. Choose the classification at field definition time β fixing it later is a schema change.
See sample: `data-classification-required-on-pii-fields.good.al`.
diff --git a/microsoft/knowledge/privacy/error-direct-substitution-safe-for-telemetry.md b/microsoft/knowledge/privacy/error-direct-substitution-safe-for-telemetry.md
index 8653ecf..f4cac77 100644
--- a/microsoft/knowledge/privacy/error-direct-substitution-safe-for-telemetry.md
+++ b/microsoft/knowledge/privacy/error-direct-substitution-safe-for-telemetry.md
@@ -1,5 +1,5 @@
---
-bc-version: [all]
+bc-version: [20..]
domain: privacy
keywords: [error, strsubstno, direct-substitution, telemetry, classification, label]
technologies: [al]
@@ -7,18 +7,18 @@ countries: [w1]
application-area: [all]
---
-# `Error()` with direct substitution parameters is always safe for telemetry
+# Use a Label or TextConst for the Error telemetry message
## 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.
+For Error method trace telemetry, the platform includes the AL error string only when `Error` receives a `Label` or `TextConst` as its first argument. Substitution values format the client message, but the static label supplies the telemetry message and preserves its classification context. A string literal, local `Text`, `StrSubstNo` result, or concatenation is not equivalent: telemetry substitutes generic guidance instead of that dynamic string.
## 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.
+Define the complete error template as a `Label` with placeholder comments, pass the label directly as the first argument, and pass values separately. Independently review whether those values are appropriate to show to the current user.
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`.
+Assuming that any direct format string is telemetry-safe, or that a `StrSubstNo`/concatenated first argument is logged verbatim. The required telemetry shape is specifically a directly supplied `Label` or `TextConst`; see `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
index f7372b7..2654dff 100644
--- a/microsoft/knowledge/privacy/error-vs-message-telemetry-logging.md
+++ b/microsoft/knowledge/privacy/error-vs-message-telemetry-logging.md
@@ -1,5 +1,5 @@
---
-bc-version: [all]
+bc-version: [20..]
domain: privacy
keywords: [error, message, confirm, notification, telemetry, logging, ui-dialog]
technologies: [al]
@@ -7,16 +7,16 @@ countries: [w1]
application-area: [all]
---
-# Only `Error()` is logged to telemetry β `Message`, `Confirm`, `Notification` are not
+# Error dialogs emit Error method trace telemetry
## 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.
+When `Error` displays a dialog, Business Central emits the RT0030 Error method trace telemetry signal. `Message`, `Confirm`, and `Notification` do not emit that Error method trace signal. For RT0030, the actual AL error string is included only when the first `Error` argument is a `Label` or `TextConst`; other first-argument types produce generic guidance instead of the dynamic string.
## 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.
+Use a `Label` or `TextConst` as the direct first argument to `Error` so telemetry contains a stable, classified message. Review user-facing substitution values for UI appropriateness. Do not treat `Message`, `Confirm`, or `Notification` content as though it were automatically copied into RT0030.
## 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.
+Claiming that every rendered `Error` string is written verbatim to telemetry, or that `Message`, `Confirm`, and `Notification` automatically feed the Error method trace. Both overstate the platform behavior.
diff --git a/microsoft/knowledge/privacy/errorinfo-telemetry-classification-and-errortype.bad.al b/microsoft/knowledge/privacy/errorinfo-telemetry-classification-and-errortype.bad.al
new file mode 100644
index 0000000..6fc5b06
--- /dev/null
+++ b/microsoft/knowledge/privacy/errorinfo-telemetry-classification-and-errortype.bad.al
@@ -0,0 +1,12 @@
+codeunit 50308 "ErrorInfo Privacy Bad"
+{
+ procedure RaiseSynchronizationError(Customer: Record Customer)
+ var
+ FailureInfo: ErrorInfo;
+ begin
+ FailureInfo.Message := StrSubstNo('Synchronization failed for %1.', Customer."E-Mail");
+ FailureInfo.DataClassification := DataClassification::SystemMetadata;
+ FailureInfo.ErrorType := ErrorType::Internal;
+ Error(FailureInfo);
+ end;
+}
diff --git a/microsoft/knowledge/privacy/errorinfo-telemetry-classification-and-errortype.good.al b/microsoft/knowledge/privacy/errorinfo-telemetry-classification-and-errortype.good.al
new file mode 100644
index 0000000..1da3c1f
--- /dev/null
+++ b/microsoft/knowledge/privacy/errorinfo-telemetry-classification-and-errortype.good.al
@@ -0,0 +1,15 @@
+codeunit 50307 "ErrorInfo Privacy Good"
+{
+ procedure RaiseSynchronizationError()
+ var
+ FailureInfo: ErrorInfo;
+ begin
+ FailureInfo.Message := SynchronizationFailedErr;
+ FailureInfo.DataClassification := DataClassification::SystemMetadata;
+ FailureInfo.ErrorType := ErrorType::Client;
+ Error(FailureInfo);
+ end;
+
+ var
+ SynchronizationFailedErr: Label 'The synchronization could not be completed.';
+}
diff --git a/microsoft/knowledge/privacy/errorinfo-telemetry-classification-and-errortype.md b/microsoft/knowledge/privacy/errorinfo-telemetry-classification-and-errortype.md
new file mode 100644
index 0000000..ce1b684
--- /dev/null
+++ b/microsoft/knowledge/privacy/errorinfo-telemetry-classification-and-errortype.md
@@ -0,0 +1,26 @@
+---
+bc-version: [14..]
+domain: privacy
+keywords: [errorinfo, errorinfo-message, errorinfo-dataclassification, errorinfo-errortype, errorinfo-detailedmessage, copy-details, telemetry]
+technologies: [al]
+countries: [w1]
+application-area: [all]
+---
+
+# Review each ErrorInfo text surface by its actual exposure
+
+## Description
+
+Runtime 3.0 (BC 14) provides `ErrorInfo.Message`, `DataClassification`, and `ErrorType`. `Message` is sent to telemetry; with `ErrorType::Client` it is also the primary client message, while `ErrorType::Internal` replaces it in the client with a generic message but still sends the specified text to telemetry. `DataClassification` classifies the content in `Message`; it does not make incorrectly classified personal data safe. Runtime 8.0 (BC 19) adds `DetailedMessage`, which is omitted from the primary message but included in the error dialog's **Copy details** content.
+
+## Best Practice
+
+Keep `Message` stable and classify its actual content. Choose `ErrorType` for client usability, not as a telemetry privacy boundary. On BC 19 and later, put only support-safe technical context in `DetailedMessage`, because a user can copy it from the dialog. The samples use only members available at the BC 14 article floor.
+
+See sample: `errorinfo-telemetry-classification-and-errortype.good.al`.
+
+## Anti Pattern
+
+Marking a dynamic customer-bearing `Message` as `SystemMetadata`, or assuming `ErrorType::Internal` keeps it out of telemetry. On BC 19 and later, the same anti-pattern includes placing secrets or personal data in `DetailedMessage` because it is not the primary dialog text.
+
+See sample: `errorinfo-telemetry-classification-and-errortype.bad.al`.
diff --git a/microsoft/knowledge/privacy/featuretelemetry-logerror-implicit-errortext.bad.al b/microsoft/knowledge/privacy/featuretelemetry-logerror-implicit-errortext.bad.al
new file mode 100644
index 0000000..6906077
--- /dev/null
+++ b/microsoft/knowledge/privacy/featuretelemetry-logerror-implicit-errortext.bad.al
@@ -0,0 +1,25 @@
+codeunit 50310 "LogError Privacy Bad"
+{
+ procedure SendInvoice()
+ var
+ FeatureTelemetry: Codeunit "Feature Telemetry";
+ CustomDimensions: Dictionary of [Text, Text];
+ ErrorCallStack: Text;
+ ErrorText: Text;
+ begin
+ if TrySendInvoice() then
+ exit;
+
+ ErrorText := GetLastErrorText();
+ ErrorCallStack := GetLastErrorCallStack();
+ CustomDimensions.Add('Operation', 'SendInvoice');
+ FeatureTelemetry.LogError('0000FT2', 'Invoice exchange', 'Sending invoice',
+ ErrorText, ErrorCallStack, CustomDimensions);
+ end;
+
+ [TryFunction]
+ local procedure TrySendInvoice()
+ begin
+ Error('Invoice %1 for %2 could not be sent.', 'INV-1001', 'user@example.com');
+ end;
+}
diff --git a/microsoft/knowledge/privacy/featuretelemetry-logerror-implicit-errortext.good.al b/microsoft/knowledge/privacy/featuretelemetry-logerror-implicit-errortext.good.al
new file mode 100644
index 0000000..cae02be
--- /dev/null
+++ b/microsoft/knowledge/privacy/featuretelemetry-logerror-implicit-errortext.good.al
@@ -0,0 +1,28 @@
+codeunit 50309 "LogError Privacy Good"
+{
+ procedure SendInvoice()
+ var
+ FeatureTelemetry: Codeunit "Feature Telemetry";
+ CustomDimensions: Dictionary of [Text, Text];
+ ErrorCallStack: Text;
+ ErrorText: Text;
+ begin
+ if TrySendInvoice() then
+ exit;
+
+ ErrorText := GetLastErrorText(true);
+ ErrorCallStack := GetLastErrorCallStack();
+ CustomDimensions.Add('Operation', 'SendInvoice');
+ FeatureTelemetry.LogError('0000FT1', 'Invoice exchange', 'Sending invoice',
+ ErrorText, ErrorCallStack, CustomDimensions);
+ end;
+
+ [TryFunction]
+ local procedure TrySendInvoice()
+ begin
+ Error(SendFailedErr);
+ end;
+
+ var
+ SendFailedErr: Label 'The invoice could not be sent.';
+}
diff --git a/microsoft/knowledge/privacy/featuretelemetry-logerror-implicit-errortext.md b/microsoft/knowledge/privacy/featuretelemetry-logerror-implicit-errortext.md
new file mode 100644
index 0000000..863f3a8
--- /dev/null
+++ b/microsoft/knowledge/privacy/featuretelemetry-logerror-implicit-errortext.md
@@ -0,0 +1,26 @@
+---
+bc-version: [18..]
+domain: privacy
+keywords: [featuretelemetry, logerror, errortext, errorcallstack, alerrortext, alerrorcallstack, customdimensions]
+technologies: [al]
+countries: [w1]
+application-area: [all]
+---
+
+# FeatureTelemetry.LogError emits more than caller custom dimensions
+
+## Description
+
+`FeatureTelemetry.LogError` emits its `ErrorText` as the telemetry message and adds it as `alErrorText`. The overloads with `ErrorCallStack` also add `alErrorCallStack`. These dimensions are produced in addition to the caller-supplied `CustomDimensions` dictionary, and the Feature Telemetry implementation sends the event as `SystemMetadata`.
+
+## Best Practice
+
+Review the dedicated error arguments as telemetry payload. Capture `GetLastErrorText(true)` when scrubbed platform error text is sufficient, and pass `GetLastErrorCallStack()` only as a call stack. Keep custom dimensions non-personal too.
+
+See sample: `featuretelemetry-logerror-implicit-errortext.good.al`.
+
+## Anti Pattern
+
+Approving a `LogError` call because its explicit dictionary contains only safe values while it passes unsanitized `GetLastErrorText()` or arbitrary context through `ErrorText` or `ErrorCallStack`. Those arguments become telemetry dimensions outside the dictionary.
+
+See sample: `featuretelemetry-logerror-implicit-errortext.bad.al`.
diff --git a/microsoft/knowledge/privacy/getlasterrortext-customer-content-in-errors.bad.al b/microsoft/knowledge/privacy/getlasterrortext-customer-content-in-errors.bad.al
index b943031..432aee9 100644
--- a/microsoft/knowledge/privacy/getlasterrortext-customer-content-in-errors.bad.al
+++ b/microsoft/knowledge/privacy/getlasterrortext-customer-content-in-errors.bad.al
@@ -2,12 +2,18 @@ codeunit 50209 "Privacy Sample GetLastError Bad"
{
procedure AddAttachment()
var
- ErrorMsg: Text;
+ AttachmentFailedErr: Label 'Attachment failed: %1', Comment = '%1 = underlying error';
begin
- if not TryAddAttachment() then begin
- ErrorMsg := StrSubstNo('Attachment failed: %1', GetLastErrorText(true));
- Error(ErrorMsg);
- end;
+ if not TryAddAttachment() then
+ Error(StrSubstNo(AttachmentFailedErr, GetLastErrorText()));
+ end;
+
+ procedure AddAttachmentWithConcatenation()
+ var
+ AttachmentFailedErr: Label 'Attachment failed: ';
+ begin
+ if not TryAddAttachment() then
+ Error(AttachmentFailedErr + GetLastErrorText());
end;
[TryFunction]
diff --git a/microsoft/knowledge/privacy/getlasterrortext-customer-content-in-errors.good.al b/microsoft/knowledge/privacy/getlasterrortext-customer-content-in-errors.good.al
index 4b07537..ff63592 100644
--- a/microsoft/knowledge/privacy/getlasterrortext-customer-content-in-errors.good.al
+++ b/microsoft/knowledge/privacy/getlasterrortext-customer-content-in-errors.good.al
@@ -2,15 +2,15 @@ codeunit 50208 "Privacy Sample GetLastError Good"
{
procedure AddAttachmentSafely()
var
- AttachmentFailedErr: Label 'Failed to add email attachment. Please try again.';
+ AttachmentFailedErr: Label 'Failed to add the attachment: %1', Comment = '%1 = underlying error shown to the user';
begin
if not TryAddAttachment() then
- Error(AttachmentFailedErr);
+ Error(AttachmentFailedErr, GetLastErrorText());
end;
[TryFunction]
local procedure TryAddAttachment()
begin
- // ... attachment logic that may fail with a customer-data-bearing error ...
+ // Attachment logic that can 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
index 769a3f5..8ff26a8 100644
--- a/microsoft/knowledge/privacy/getlasterrortext-customer-content-in-errors.md
+++ b/microsoft/knowledge/privacy/getlasterrortext-customer-content-in-errors.md
@@ -1,5 +1,5 @@
---
-bc-version: [all]
+bc-version: [20..]
domain: privacy
keywords: [getlasterrortext, error, strsubstno, telemetry, customer-data, attachment]
technologies: [al]
@@ -11,16 +11,16 @@ application-area: [all]
## 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`).
+Parameterless `GetLastErrorText()` can contain customer content such as field values, record keys, and file names. The Boolean overload names its parameter `ExcludeCustomerContent`; passing `true` requests scrubbed text and is not the customer-content scenario covered here. When unsanitized error text is passed as a substitution value to an `Error` whose first argument is a `Label` or `TextConst`, the label supplies the Error method trace telemetry message.
## 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.
+Use a generic label when the user does not need the underlying detail. If showing unsanitized detail is appropriate, put `%1` in a label and pass parameterless `GetLastErrorText()` as a separate argument. This preserves a useful static telemetry message while keeping the dynamic value out of the telemetry message field.
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.
+`Error(StrSubstNo(AttachmentFailedErr, GetLastErrorText()))` or `Error(AttachmentPrefixErr + GetLastErrorText())`. Both lose the static first argument and trigger AA0231; neither causes the composed text to be logged verbatim as the Error telemetry message.
See sample: `getlasterrortext-customer-content-in-errors.bad.al`.
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
index 6308674..d0d1c4a 100644
--- 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
@@ -4,10 +4,14 @@ codeunit 50217 "Privacy Sample Consent Bad"
var
HttpClient: HttpClient;
Content: HttpContent;
+ Payload: JsonObject;
+ PayloadText: Text;
Response: HttpResponseMessage;
begin
- Content.WriteFrom(StrSubstNo('{"email":"%1","name":"%2"}',
- Customer."E-Mail", Customer.Name));
+ Payload.Add('email', Customer."E-Mail");
+ Payload.Add('name', Customer.Name);
+ Payload.WriteTo(PayloadText);
+ Content.WriteFrom(PayloadText);
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
index 0ac939c..3c59759 100644
--- 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
@@ -1,22 +1,35 @@
codeunit 50216 "Privacy Sample Consent Good"
{
+ var
+ ExternalSyncNoticeIdLbl: Label 'CONTOSO-EXTERNAL-SYNC', Locked = true;
+ ExternalSyncNameLbl: Label 'Contoso External Sync', Locked = true;
+ PrivacyTermsUrlLbl: Label 'https://contoso.example/privacy', Locked = true;
+
+ internal procedure RegisterPrivacyNotice()
+ var
+ PrivacyNotice: Codeunit "Privacy Notice";
+ begin
+ PrivacyNotice.CreatePrivacyNotice(
+ ExternalSyncNoticeIdLbl, ExternalSyncNameLbl, PrivacyTermsUrlLbl);
+ end;
+
procedure SendDataToExternalService(Customer: Record Customer)
var
PrivacyNotice: Codeunit "Privacy Notice";
- PrivacyNoticeRegistrations: Codeunit "Privacy Notice Registrations";
HttpClient: HttpClient;
Content: HttpContent;
+ Payload: JsonObject;
+ PayloadText: Text;
Response: HttpResponseMessage;
PrivacyConsentRequiredErr: Label 'Privacy notice consent is required for this integration.';
begin
- if PrivacyNotice.GetPrivacyNoticeApprovalState(
- PrivacyNoticeRegistrations.GetExchangePrivacyNoticeId())
- <> "Privacy Notice Approval State"::Agreed
- then
+ if not PrivacyNotice.ConfirmPrivacyNoticeApproval(ExternalSyncNoticeIdLbl) then
Error(PrivacyConsentRequiredErr);
- Content.WriteFrom(StrSubstNo('{"email":"%1","name":"%2"}',
- Customer."E-Mail", Customer.Name));
+ Payload.Add('email', Customer."E-Mail");
+ Payload.Add('name', Customer.Name);
+ Payload.WriteTo(PayloadText);
+ Content.WriteFrom(PayloadText);
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
index a064792..d95acef 100644
--- a/microsoft/knowledge/privacy/privacy-notice-consent-for-external-data-transfer.md
+++ b/microsoft/knowledge/privacy/privacy-notice-consent-for-external-data-transfer.md
@@ -1,26 +1,26 @@
---
bc-version: [all]
domain: privacy
-keywords: [privacy-notice, consent, http-client, outgoing-request, external-service, getprivacynoticeapprovalstate]
+keywords: [privacy-notice, consent, http-client, outgoing-request, external-service, confirmprivacynoticeapproval]
technologies: [al]
countries: [w1]
application-area: [all]
---
-# Outgoing requests to external services require a Privacy Notice consent check
+# Check the custom Privacy Notice before external data transfer
## 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.
+Business Central's `Codeunit "Privacy Notice"` creates notices and records per-integration approval. A custom integration needs its own stable notice ID; it must not borrow the Exchange or another built-in service's consent. `ConfirmPrivacyNoticeApproval` shows the notice when needed and returns whether the request is approved. `GetPrivacyNoticeApprovalState` checks an existing notice without showing UI.
## 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.
+Register the custom notice with `CreatePrivacyNotice` during setup or through `OnRegisterPrivacyNotices`. Before sending data, call `ConfirmPrivacyNoticeApproval()` outside a write transaction, or check `GetPrivacyNoticeApprovalState()` when the flow must not show UI. No path should issue the request without approval.
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.
+A custom integration that posts data without checking its own notice, or that gates the call with a built-in ID such as the Exchange privacy notice ID. Consent for one service does not authorize another.
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
index d8a20f5..4a93cbe 100644
--- a/microsoft/knowledge/privacy/register-integration-in-privacy-notice-registrations.good.al
+++ b/microsoft/knowledge/privacy/register-integration-in-privacy-notice-registrations.good.al
@@ -1,11 +1,17 @@
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";
+ ExternalSyncNoticeIdLbl: Label 'CONTOSO-EXTERNAL-SYNC', Locked = true;
+ ExternalSyncNameLbl: Label 'Contoso External Sync', Locked = true;
+ PrivacyTermsUrlLbl: Label 'https://contoso.example/privacy', Locked = true;
+
+ [EventSubscriber(ObjectType::Codeunit, Codeunit::"Privacy Notice", 'OnRegisterPrivacyNotices', '', false, false)]
+ local procedure OnRegisterPrivacyNotices(var TempPrivacyNotice: Record "Privacy Notice" temporary)
begin
- PrivacyNotice.CreatePrivacyNoticeForIntegration(
- 'My External Sync', 'External Customer Sync Service');
+ TempPrivacyNotice.Init();
+ TempPrivacyNotice.ID := ExternalSyncNoticeIdLbl;
+ TempPrivacyNotice."Integration Service Name" := ExternalSyncNameLbl;
+ TempPrivacyNotice.Link := PrivacyTermsUrlLbl;
+ if not TempPrivacyNotice.Insert() then;
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
index 40779e9..4e76779 100644
--- a/microsoft/knowledge/privacy/register-integration-in-privacy-notice-registrations.md
+++ b/microsoft/knowledge/privacy/register-integration-in-privacy-notice-registrations.md
@@ -1,24 +1,24 @@
---
bc-version: [all]
domain: privacy
-keywords: [privacy-notice-registrations, integration, register, exchange, onedrive, teams, notice-id]
+keywords: [privacy-notice, integration, register, onregisterprivacynotices, notice-id]
technologies: [al]
countries: [w1]
application-area: [all]
---
-# Register every new external integration with `Privacy Notice Registrations`
+# Register custom integrations with Codeunit Privacy Notice
## 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.
+The current extension point is `Codeunit "Privacy Notice"`. Extensions can subscribe to its `OnRegisterPrivacyNotices` event and add a dedicated notice ID, integration name, and link to the temporary `Privacy Notice` record. For explicit creation outside the default-registration flow, the same codeunit exposes `CreatePrivacyNotice`. `Codeunit "Privacy Notice Registrations"` contains IDs for built-in integrations and is not the registration API for a custom service.
## 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`.
+Choose a stable ID owned by the extension. Register it through `OnRegisterPrivacyNotices`, or call `PrivacyNotice.CreatePrivacyNotice` during an intentional setup or upgrade path. Use that same ID for consent checks 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.
+Reusing the Exchange or another built-in notice ID for a custom integration, subscribing to `Privacy Notice Registrations`, or calling the nonexistent `CreatePrivacyNoticeForIntegration` method. These shapes attach consent to the wrong service or do not compile.
diff --git a/microsoft/knowledge/privacy/table-level-data-classification-cascades.good.al b/microsoft/knowledge/privacy/table-level-data-classification-cascades.good.al
index e1e5808..80a4345 100644
--- a/microsoft/knowledge/privacy/table-level-data-classification-cascades.good.al
+++ b/microsoft/knowledge/privacy/table-level-data-classification-cascades.good.al
@@ -1,12 +1,19 @@
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]) { }
+ field(1; "Entry No."; Integer)
+ {
+ DataClassification = SystemMetadata;
+ }
+ field(2; "Changed By"; Code[50])
+ {
+ DataClassification = EndUserIdentifiableInformation;
+ }
+ field(3; "Change Description"; Text[250])
+ {
+ DataClassification = CustomerContent;
+ }
}
keys
diff --git a/microsoft/knowledge/privacy/table-level-data-classification-cascades.md b/microsoft/knowledge/privacy/table-level-data-classification-cascades.md
index bf457e9..cd31d07 100644
--- a/microsoft/knowledge/privacy/table-level-data-classification-cascades.md
+++ b/microsoft/knowledge/privacy/table-level-data-classification-cascades.md
@@ -1,24 +1,24 @@
---
bc-version: [all]
domain: privacy
-keywords: [data-classification, table-level, inheritance, override, cascading]
+keywords: [data-classification, table-level, normal-field, appsourcecop, as0016]
technologies: [al]
countries: [w1]
application-area: [all]
---
-# Table-level DataClassification cascades to every field unless overridden
+# Set DataClassification on every Normal table field
## 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.
+AppSourceCop AS0016 requires every field whose `FieldClass` is `Normal` to declare `DataClassification` and use a value other than `ToBeClassified`. A table-level `DataClassification` property does not satisfy that field-level requirement. FlowFields and FlowFilters are handled separately by the platform and are covered by `flowfield-flowfilter-classification-systemmetadata.md`.
## 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.
+Classify each Normal field according to the data it stores, even when every field in the table has the same classification. Repeat the property explicitly so AS0016 can verify every field.
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.
+Relying on `DataClassification` at table scope and leaving Normal fields unclassified. The table property does not cascade in the way AS0016 requires, so the fields still fail AppSourceCop validation.
diff --git a/community/knowledge/security/classify-every-field-with-dataclassification.bad.al b/microsoft/knowledge/security/classify-every-field-with-dataclassification.bad.al
similarity index 100%
rename from community/knowledge/security/classify-every-field-with-dataclassification.bad.al
rename to microsoft/knowledge/security/classify-every-field-with-dataclassification.bad.al
diff --git a/community/knowledge/security/classify-every-field-with-dataclassification.good.al b/microsoft/knowledge/security/classify-every-field-with-dataclassification.good.al
similarity index 100%
rename from community/knowledge/security/classify-every-field-with-dataclassification.good.al
rename to microsoft/knowledge/security/classify-every-field-with-dataclassification.good.al
diff --git a/community/knowledge/security/classify-every-field-with-dataclassification.md b/microsoft/knowledge/security/classify-every-field-with-dataclassification.md
similarity index 100%
rename from community/knowledge/security/classify-every-field-with-dataclassification.md
rename to microsoft/knowledge/security/classify-every-field-with-dataclassification.md
diff --git a/community/knowledge/security/compose-permission-sets-with-included-sets.bad.al b/microsoft/knowledge/security/compose-permission-sets-with-included-sets.bad.al
similarity index 100%
rename from community/knowledge/security/compose-permission-sets-with-included-sets.bad.al
rename to microsoft/knowledge/security/compose-permission-sets-with-included-sets.bad.al
diff --git a/community/knowledge/security/compose-permission-sets-with-included-sets.good.al b/microsoft/knowledge/security/compose-permission-sets-with-included-sets.good.al
similarity index 100%
rename from community/knowledge/security/compose-permission-sets-with-included-sets.good.al
rename to microsoft/knowledge/security/compose-permission-sets-with-included-sets.good.al
diff --git a/community/knowledge/security/compose-permission-sets-with-included-sets.md b/microsoft/knowledge/security/compose-permission-sets-with-included-sets.md
similarity index 95%
rename from community/knowledge/security/compose-permission-sets-with-included-sets.md
rename to microsoft/knowledge/security/compose-permission-sets-with-included-sets.md
index 7fb94cb..cdeec49 100644
--- a/community/knowledge/security/compose-permission-sets-with-included-sets.md
+++ b/microsoft/knowledge/security/compose-permission-sets-with-included-sets.md
@@ -9,8 +9,6 @@ application-area: [all]
# Compose permission sets with IncludedPermissionSets
-> Contributions welcome β open a PR to refine or extend this article.
-
## Description
The `IncludedPermissionSets` property lets one AL permission set reference another, composing rights out of smaller building blocks. Combined with `Assignable = false` on the building blocks, an extension can ship focused per-module units (a table-data cluster, an API-access cluster) and assemble role-shaped sets that include them. Adding an object updates one building block, and every role-shaped set that includes it inherits the change automatically β instead of drifting apart across duplicated definitions.
diff --git a/community/knowledge/security/do-not-grant-rights-beyond-a-users-entitlement.md b/microsoft/knowledge/security/do-not-grant-rights-beyond-a-users-entitlement.md
similarity index 91%
rename from community/knowledge/security/do-not-grant-rights-beyond-a-users-entitlement.md
rename to microsoft/knowledge/security/do-not-grant-rights-beyond-a-users-entitlement.md
index 5334ab7..bc9a4e8 100644
--- a/community/knowledge/security/do-not-grant-rights-beyond-a-users-entitlement.md
+++ b/microsoft/knowledge/security/do-not-grant-rights-beyond-a-users-entitlement.md
@@ -9,8 +9,6 @@ application-area: [all]
# Do not grant rights beyond a user's entitlement
-> Contributions welcome β open a PR to refine or extend this article.
-
## Description
Entitlements are license-level caps on what a user can access, derived automatically from the BC license tier. Permission sets are application-level grants administered on top of the entitlement. A permission set can only grant within the entitlement's boundaries; grants beyond those boundaries are silently clipped at runtime. This means a permission set authored and validated in a developer sandbox (with a broad license) can appear to work correctly there and fail silently in a customer tenant where users hold a narrower entitlement.
@@ -19,8 +17,6 @@ Entitlements are license-level caps on what a user can access, derived automatic
When designing a permission set that ships with an extension, consult the entitlement model for the target user population before finalizing the grants. Every object and tabledata right the set expects to grant should be reachable within the intended entitlement tier; if it is not, the set needs to be scoped to licenses that permit it, or the feature needs a different access path.
-See sample: `do-not-grant-rights-beyond-a-users-entitlement.good.al`.
-
## Anti Pattern
Authoring permission sets in a sandbox with full-license context and shipping them without verifying which entitlement tier customer users actually hold. The sets look complete in test; on a real customer they silently lose rights at runtime and the symptom is "the feature does not work for some users" with no obvious authorization error.
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
index 22e279b..8dd0069 100644
--- 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
@@ -8,7 +8,7 @@ codeunit 50215 "Sec Sample IsoStorage Good"
exit(true);
end;
- internal procedure SetApiKey(NewKey: Text)
+ internal procedure SetApiKey(NewKey: SecretText)
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
index cbf5d5d..d887d92 100644
--- a/microsoft/knowledge/security/isolatedstorage-access-must-be-local-or-internal.md
+++ b/microsoft/knowledge/security/isolatedstorage-access-must-be-local-or-internal.md
@@ -1,5 +1,5 @@
---
-bc-version: [all]
+bc-version: [24..]
domain: security
keywords: [isolatedstorage, local, internal, public, getter, setter, encapsulation]
technologies: [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
index 60efe31..63a8649 100644
--- a/microsoft/knowledge/security/isolatedstorage-datascope-module-vs-company.bad.al
+++ b/microsoft/knowledge/security/isolatedstorage-datascope-module-vs-company.bad.al
@@ -1,6 +1,6 @@
codeunit 50220 "Sec Sample DataScope Bad"
{
- internal procedure StoreCompanyWebhook(WebhookUrl: Text)
+ internal procedure StoreCompanyWebhook(WebhookUrl: SecretText)
begin
IsolatedStorage.SetEncrypted('WebhookUrl', WebhookUrl, DataScope::Module);
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
index 397635f..f91bb26 100644
--- a/microsoft/knowledge/security/isolatedstorage-datascope-module-vs-company.good.al
+++ b/microsoft/knowledge/security/isolatedstorage-datascope-module-vs-company.good.al
@@ -1,11 +1,11 @@
codeunit 50219 "Sec Sample DataScope Good"
{
- internal procedure StoreTenantApiKey(ApiKey: Text)
+ internal procedure StoreTenantApiKey(ApiKey: SecretText)
begin
IsolatedStorage.SetEncrypted('TenantApiKey', ApiKey, DataScope::Module);
end;
- internal procedure StoreCompanyWebhook(WebhookUrl: Text)
+ internal procedure StoreCompanyWebhook(WebhookUrl: SecretText)
begin
IsolatedStorage.SetEncrypted('WebhookUrl', WebhookUrl, DataScope::Company);
end;
diff --git a/microsoft/knowledge/security/isolatedstorage-datascope-module-vs-company.md b/microsoft/knowledge/security/isolatedstorage-datascope-module-vs-company.md
index 711895d..111daf0 100644
--- a/microsoft/knowledge/security/isolatedstorage-datascope-module-vs-company.md
+++ b/microsoft/knowledge/security/isolatedstorage-datascope-module-vs-company.md
@@ -1,5 +1,5 @@
---
-bc-version: [all]
+bc-version: [24..]
domain: security
keywords: [isolatedstorage, datascope, module, company, user, scope]
technologies: [al]
diff --git a/microsoft/knowledge/security/isolatedstorage-setencrypted-for-sensitive-values.good.al b/microsoft/knowledge/security/isolatedstorage-setencrypted-for-sensitive-values.good.al
index 215055c..ec1adcc 100644
--- a/microsoft/knowledge/security/isolatedstorage-setencrypted-for-sensitive-values.good.al
+++ b/microsoft/knowledge/security/isolatedstorage-setencrypted-for-sensitive-values.good.al
@@ -1,10 +1,11 @@
codeunit 50217 "Sec Sample SetEncrypted Good"
{
- internal procedure StoreApiKey(ApiKeyValue: Text)
+ internal procedure StoreApiKey(ApiKeyValue: SecretText)
+ var
+ StoreApiKeyFailedErr: Label 'The API key could not be stored.';
begin
- if StrLen(ApiKeyValue) > 200 then
- Error('API key too long for encrypted storage');
- IsolatedStorage.SetEncrypted('ApiKey', ApiKeyValue, DataScope::Module);
+ if not IsolatedStorage.SetEncrypted('ApiKey', ApiKeyValue, DataScope::Module) then
+ Error(StoreApiKeyFailedErr);
end;
local procedure ReadApiKey(var ApiKey: SecretText): Boolean
diff --git a/microsoft/knowledge/security/isolatedstorage-setencrypted-for-sensitive-values.md b/microsoft/knowledge/security/isolatedstorage-setencrypted-for-sensitive-values.md
index 4e4f6b9..bf2019d 100644
--- a/microsoft/knowledge/security/isolatedstorage-setencrypted-for-sensitive-values.md
+++ b/microsoft/knowledge/security/isolatedstorage-setencrypted-for-sensitive-values.md
@@ -1,5 +1,5 @@
---
-bc-version: [all]
+bc-version: [24..]
domain: security
keywords: [isolatedstorage, setencrypted, encryption, secret, storage]
technologies: [al]
@@ -15,7 +15,7 @@ application-area: [all]
## 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`.
+Use the `SecretText` overloads of `IsolatedStorage.SetEncrypted` and `IsolatedStorage.Get` for values that meet the definition of a secret. Check the optional Boolean result when storage failure needs a controlled error; encrypted values are subject to the documented storage-size limit. See sample: `isolatedstorage-setencrypted-for-sensitive-values.good.al`.
## Anti Pattern
diff --git a/microsoft/knowledge/security/nondebuggable-required-when-unwrapping-secrettext.bad.al b/microsoft/knowledge/security/nondebuggable-required-when-unwrapping-secrettext.bad.al
index a22b60f..ece0fd8 100644
--- a/microsoft/knowledge/security/nondebuggable-required-when-unwrapping-secrettext.bad.al
+++ b/microsoft/knowledge/security/nondebuggable-required-when-unwrapping-secrettext.bad.al
@@ -1,19 +1,14 @@
codeunit 50214 "Sec Sample NonDebug Bad"
{
- procedure BuildConnectionString(ApiKey: SecretText): Text
+ procedure CallLegacyOnPremisesConsumer(ApiKey: SecretText)
+ var
+ PlainApiKey: Text;
begin
- exit('Server=db.example.com;Key=' + ApiKey.Unwrap());
+ PlainApiKey := ApiKey.Unwrap();
+ InvokeLegacyConsumer(PlainApiKey);
end;
- procedure ParseSessionToken(Response: HttpResponseMessage; var SessionToken: SecretText)
- var
- ResponseText: Text;
- JsonObject: JsonObject;
- JsonToken: JsonToken;
+ local procedure InvokeLegacyConsumer(ApiKey: Text)
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
index 421e9bd..6b4c045 100644
--- a/microsoft/knowledge/security/nondebuggable-required-when-unwrapping-secrettext.good.al
+++ b/microsoft/knowledge/security/nondebuggable-required-when-unwrapping-secrettext.good.al
@@ -1,21 +1,17 @@
codeunit 50213 "Sec Sample NonDebug Good"
{
[NonDebuggable]
- procedure BuildConnectionString(ApiKey: SecretText): Text
+ procedure CallLegacyOnPremisesConsumer(ApiKey: SecretText)
+ var
+ PlainApiKey: Text;
begin
- exit('Server=db.example.com;Key=' + ApiKey.Unwrap());
+ PlainApiKey := ApiKey.Unwrap();
+ InvokeLegacyConsumer(PlainApiKey);
end;
[NonDebuggable]
- procedure ParseSessionToken(Response: HttpResponseMessage; var SessionToken: SecretText)
- var
- ResponseText: Text;
- JsonObject: JsonObject;
- JsonToken: JsonToken;
+ local procedure InvokeLegacyConsumer(ApiKey: Text)
begin
- Response.Content.ReadAs(ResponseText);
- JsonObject.ReadFrom(ResponseText);
- JsonObject.Get('access_token', JsonToken);
- SessionToken := JsonToken.AsValue().AsText();
+ // The on-premises legacy consumer accepts only Text.
end;
}
diff --git a/microsoft/knowledge/security/nondebuggable-required-when-unwrapping-secrettext.md b/microsoft/knowledge/security/nondebuggable-required-when-unwrapping-secrettext.md
index b214977..2c5dce8 100644
--- a/microsoft/knowledge/security/nondebuggable-required-when-unwrapping-secrettext.md
+++ b/microsoft/knowledge/security/nondebuggable-required-when-unwrapping-secrettext.md
@@ -1,5 +1,5 @@
---
-bc-version: [all]
+bc-version: [23..]
domain: security
keywords: [nondebuggable, attribute, secrettext, unwrap, debugger]
technologies: [al]
@@ -7,16 +7,16 @@ countries: [w1]
application-area: [all]
---
-# Mark procedures that call SecretText.Unwrap() as [NonDebuggable]
+# On-premises only: protect unavoidable SecretText.Unwrap calls
## 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).
+`SecretText.Unwrap()` is supported only for Business Central on-premises and exists for compatibility. It converts a protected value to plain `Text`, where debugger redaction no longer applies. `[NonDebuggable]` prevents the debugger from inspecting a procedure's parameters and locals, but it does not make the resulting `Text` safe to return, log, or pass through debuggable code.
## 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`.
+In SaaS, keep the value as `SecretText` and use secret-aware APIs instead of unwrapping. For an unavoidable on-premises legacy API that accepts only `Text`, keep the plain-text path as short as possible and mark every procedure in that path `[NonDebuggable]`. Do not return the unwrapped value. 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`.
+Calling `Unwrap()` in cloud-targeted code, or calling it in an on-premises procedure that is debuggable or returns the resulting `Text`. Both defeat the protection that `SecretText` provides. See sample: `nondebuggable-required-when-unwrapping-secrettext.bad.al`.
diff --git a/community/knowledge/security/prefer-oauth2-over-api-keys-for-external-http-calls.bad.al b/microsoft/knowledge/security/prefer-oauth2-over-api-keys-for-external-http-calls.bad.al
similarity index 100%
rename from community/knowledge/security/prefer-oauth2-over-api-keys-for-external-http-calls.bad.al
rename to microsoft/knowledge/security/prefer-oauth2-over-api-keys-for-external-http-calls.bad.al
diff --git a/community/knowledge/security/prefer-oauth2-over-api-keys-for-external-http-calls.good.al b/microsoft/knowledge/security/prefer-oauth2-over-api-keys-for-external-http-calls.good.al
similarity index 100%
rename from community/knowledge/security/prefer-oauth2-over-api-keys-for-external-http-calls.good.al
rename to microsoft/knowledge/security/prefer-oauth2-over-api-keys-for-external-http-calls.good.al
diff --git a/community/knowledge/security/prefer-oauth2-over-api-keys-for-external-http-calls.md b/microsoft/knowledge/security/prefer-oauth2-over-api-keys-for-external-http-calls.md
similarity index 96%
rename from community/knowledge/security/prefer-oauth2-over-api-keys-for-external-http-calls.md
rename to microsoft/knowledge/security/prefer-oauth2-over-api-keys-for-external-http-calls.md
index f12a4f9..131bb9b 100644
--- a/community/knowledge/security/prefer-oauth2-over-api-keys-for-external-http-calls.md
+++ b/microsoft/knowledge/security/prefer-oauth2-over-api-keys-for-external-http-calls.md
@@ -9,8 +9,6 @@ application-area: [all]
# Prefer OAuth2 over API keys for external HTTP calls
-> Contributions welcome β open a PR to refine or extend this article.
-
## Description
External HTTP integrations from AL can authenticate using OAuth 2.0 (client-credentials for service-to-service, authorization-code for user-delegated), API keys, basic authentication, or credentials in URLs. The mechanisms differ substantially in the blast radius of a leaked secret and in how cleanly tokens can be rotated. OAuth-issued tokens expire on their own schedule and rotate cleanly; API keys and basic-auth passwords typically have to be rotated manually and usually live unencrypted in a configuration table. When the partner supports OAuth, the difference is a material security improvement, not a stylistic preference.
diff --git a/community/knowledge/security/protect-sensitive-data-in-temporary-tables.bad.al b/microsoft/knowledge/security/protect-sensitive-data-in-temporary-tables.bad.al
similarity index 100%
rename from community/knowledge/security/protect-sensitive-data-in-temporary-tables.bad.al
rename to microsoft/knowledge/security/protect-sensitive-data-in-temporary-tables.bad.al
diff --git a/community/knowledge/security/protect-sensitive-data-in-temporary-tables.good.al b/microsoft/knowledge/security/protect-sensitive-data-in-temporary-tables.good.al
similarity index 90%
rename from community/knowledge/security/protect-sensitive-data-in-temporary-tables.good.al
rename to microsoft/knowledge/security/protect-sensitive-data-in-temporary-tables.good.al
index 54bf2bb..a0c9f77 100644
--- a/community/knowledge/security/protect-sensitive-data-in-temporary-tables.good.al
+++ b/microsoft/knowledge/security/protect-sensitive-data-in-temporary-tables.good.al
@@ -19,9 +19,6 @@ codeunit 50100 "Customer Temp Processor"
until Customer.Next() = 0;
ProcessCustomerBuffer(TempCustomer);
-
- // Explicit cleanup on the normal exit path.
- TempCustomer.DeleteAll();
exit(true);
end;
diff --git a/community/knowledge/security/protect-sensitive-data-in-temporary-tables.md b/microsoft/knowledge/security/protect-sensitive-data-in-temporary-tables.md
similarity index 77%
rename from community/knowledge/security/protect-sensitive-data-in-temporary-tables.md
rename to microsoft/knowledge/security/protect-sensitive-data-in-temporary-tables.md
index 3f4db02..7eedc02 100644
--- a/community/knowledge/security/protect-sensitive-data-in-temporary-tables.md
+++ b/microsoft/knowledge/security/protect-sensitive-data-in-temporary-tables.md
@@ -9,15 +9,13 @@ application-area: [all]
# Protect sensitive data in temporary tables
-> Contributions welcome β open a PR to refine or extend this article.
-
## Description
A temporary record copies data out of the source table into session memory. The platform does not automatically enforce the source table's permission model on the copy, and a value written to a temporary buffer can outlive the procedure that put it there if the buffer is a global or is passed upward. Code that places sensitive rows into a temporary table is therefore responsible for the checks and cleanup the source table would otherwise provide.
## Best Practice
-Validate the caller's read permission on the source table before populating the temporary buffer. Keep the buffer's lifetime as short as the work requires, and delete its contents on every exit path β including error paths β so sensitive values do not linger. Prefer local temporary variables over globals for anything carrying sensitive data.
+Validate the caller's read permission on the source table before populating the temporary buffer. Keep the buffer's lifetime as short as the work requires, and prefer local temporary variables over globals for anything carrying sensitive data β a local buffer's contents are discarded automatically when the procedure returns. When a buffer must be global or is passed back to callers, delete its contents on every exit path β including error paths β so sensitive values do not linger.
See sample: `protect-sensitive-data-in-temporary-tables.good.al`.
diff --git a/microsoft/knowledge/security/secrets-isolated-storage.bad.al b/microsoft/knowledge/security/secrets-isolated-storage.bad.al
new file mode 100644
index 0000000..7383b8a
--- /dev/null
+++ b/microsoft/knowledge/security/secrets-isolated-storage.bad.al
@@ -0,0 +1,21 @@
+table 50134 "Api Setup Bad Sample"
+{
+ fields
+ {
+ field(1; "Primary Key"; Code[10]) { }
+
+ // A secret in an ordinary Text field is readable by anyone with table
+ // permission, ships in RapidStart packages and Excel exports, and
+ // appears in record snapshots. No DataClassification tag makes it safe;
+ // it belongs in IsolatedStorage instead.
+ field(10; "API Key"; Text[250])
+ {
+ DataClassification = CustomerContent;
+ }
+ }
+
+ keys
+ {
+ key(PK; "Primary Key") { Clustered = true; }
+ }
+}
diff --git a/microsoft/knowledge/security/secrets-isolated-storage.good.al b/microsoft/knowledge/security/secrets-isolated-storage.good.al
new file mode 100644
index 0000000..e6b9f38
--- /dev/null
+++ b/microsoft/knowledge/security/secrets-isolated-storage.good.al
@@ -0,0 +1,15 @@
+codeunit 50134 "Api Credential Good Sample"
+{
+ procedure StoreApiKey(ApiKey: SecretText)
+ begin
+ // Credentials live in IsolatedStorage, invisible to record reads, API
+ // pages, RapidStart packages, and Excel export.
+ IsolatedStorage.SetEncrypted('ExternalApiKey', ApiKey, DataScope::Module);
+ end;
+
+ procedure GetApiKey() ApiKey: SecretText
+ begin
+ if not IsolatedStorage.Get('ExternalApiKey', DataScope::Module, ApiKey) then
+ Error('The external API key has not been configured.');
+ end;
+}
diff --git a/microsoft/knowledge/security/secrets-isolated-storage.md b/microsoft/knowledge/security/secrets-isolated-storage.md
new file mode 100644
index 0000000..91afe69
--- /dev/null
+++ b/microsoft/knowledge/security/secrets-isolated-storage.md
@@ -0,0 +1,22 @@
+---
+bc-version: [all]
+domain: security
+keywords: [isolatedstorage, secrets, api-key, oauth-token, connection-string, table-field, credentials]
+technologies: [al]
+countries: [w1]
+application-area: [all]
+---
+
+# A secret belongs in IsolatedStorage, never in a table field
+
+## Description
+
+API keys, OAuth tokens, client secrets, and connection strings must not be stored in an ordinary table `Text` field β not even on a hidden setup table. A regular field is exposed through record reads, page display, RapidStart and Excel export, report datasets, and surfaces in `DataClassification` review; anyone with table permission can read it. The correct home is `IsolatedStorage`, which is invisible to database queries, API pages, and configuration packages. The storage-*location* decision is the rule here; how to scope and encrypt the value once it is in IsolatedStorage is covered separately.
+
+## Best Practice
+
+Persist every credential in `IsolatedStorage`, write it at the point of capture, and read it only when needed. Prefer `SetEncrypted` when the value fits its documented length limit. On BC24 and later, carry the value through the `SecretText` overloads; on earlier releases, keep any required `Text` handling inside a `[NonDebuggable]` boundary. Choose the `DataScope` that matches the credential's lifetime. See `isolatedstorage-datascope-module-vs-company`, `isolatedstorage-setencrypted-for-sensitive-values`, and `secrettext-for-credentials` for those separate concerns.
+
+## Anti Pattern
+
+A "Setup" or "Connection" table carrying a `Text` field named `API Key`, `Password`, or `Client Secret`. The value is now readable by any object with table permission, ships in RapidStart packages and Excel exports, and appears in record snapshots β a credential disclosure that no amount of encryption-in-transit elsewhere makes up for. Reviewer signal: a secret-shaped field declared on a table instead of an `IsolatedStorage` call.
diff --git a/microsoft/knowledge/security/secretstrsubstno-for-composing-secrets.bad.al b/microsoft/knowledge/security/secretstrsubstno-for-composing-secrets.bad.al
index 84dda45..7ff4232 100644
--- a/microsoft/knowledge/security/secretstrsubstno-for-composing-secrets.bad.al
+++ b/microsoft/knowledge/security/secretstrsubstno-for-composing-secrets.bad.al
@@ -1,12 +1,17 @@
codeunit 50212 "Sec Sample SecretSubst Bad"
{
- procedure BuildAuthHeader(Token: SecretText): Text
+ procedure BuildAuthHeader(Token: Text): Text
begin
- exit(StrSubstNo('Bearer %1', Token.Unwrap()));
+ exit(StrSubstNo('Token %1', Token));
end;
- procedure BuildSecretUri(BaseUrl: Text; ApiKey: SecretText): Text
+ procedure BuildSecretUri(ApiKey: Text): Text
begin
- exit(BaseUrl + '?key=' + ApiKey.Unwrap());
+ exit(StrSubstNo('https://api.example.com/data?key=%1', ApiKey));
+ end;
+
+ procedure BuildBrokenAuthHeader(Token: SecretText): SecretText
+ begin
+ exit(SecretStrSubstNo('Token', Token));
end;
}
diff --git a/microsoft/knowledge/security/secretstrsubstno-for-composing-secrets.good.al b/microsoft/knowledge/security/secretstrsubstno-for-composing-secrets.good.al
index f550025..0ef1282 100644
--- a/microsoft/knowledge/security/secretstrsubstno-for-composing-secrets.good.al
+++ b/microsoft/knowledge/security/secretstrsubstno-for-composing-secrets.good.al
@@ -2,11 +2,11 @@ codeunit 50211 "Sec Sample SecretSubst Good"
{
procedure BuildAuthHeader(Token: SecretText): SecretText
begin
- exit(SecretStrSubstNo('Bearer %1', Token));
+ exit(SecretStrSubstNo('Token %1', Token));
end;
- procedure BuildSecretUri(BaseUrl: Text; ApiKey: SecretText): SecretText
+ procedure BuildSecretUri(ApiKey: SecretText): SecretText
begin
- exit(SecretStrSubstNo('%1?key=%2', BaseUrl, ApiKey));
+ exit(SecretStrSubstNo('https://api.example.com/data?key=%1', ApiKey));
end;
}
diff --git a/microsoft/knowledge/security/secretstrsubstno-for-composing-secrets.md b/microsoft/knowledge/security/secretstrsubstno-for-composing-secrets.md
index 6e315f7..4c0fa41 100644
--- a/microsoft/knowledge/security/secretstrsubstno-for-composing-secrets.md
+++ b/microsoft/knowledge/security/secretstrsubstno-for-composing-secrets.md
@@ -1,5 +1,5 @@
---
-bc-version: [all]
+bc-version: [23..]
domain: security
keywords: [secretstrsubstno, secrettext, strsubstno, format, compose]
technologies: [al]
@@ -11,12 +11,12 @@ application-area: [all]
## 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.
+`SecretStrSubstNo` is the secret-preserving counterpart of `StrSubstNo`. It inserts `SecretText` arguments into `%1`, `%2`, and similar placeholders and returns `SecretText` without materializing the result as plain text. It is the right tool for values such as a `Token %1` authorization header or a URI with an API key placeholder.
## 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`.
+Compose every secret-bearing string through `SecretStrSubstNo`, ensure the format contains a placeholder for each secret, and keep the result as `SecretText`. Pass it to `HttpRequestMessage.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`.
+Keeping a credential in `Text` and inserting it with `StrSubstNo`, or calling `SecretStrSubstNo` with a format that has no placeholder for the secret. The first exposes the value as plain text; the second silently omits it. See sample: `secretstrsubstno-for-composing-secrets.bad.al`.
diff --git a/microsoft/knowledge/security/secrettext-for-credentials.good.al b/microsoft/knowledge/security/secrettext-for-credentials.good.al
index d98f127..2eb0355 100644
--- a/microsoft/knowledge/security/secrettext-for-credentials.good.al
+++ b/microsoft/knowledge/security/secrettext-for-credentials.good.al
@@ -1,14 +1,11 @@
codeunit 50207 "Sec Sample SecretText Good"
{
- procedure CallExternalApi()
+ procedure CallExternalApi(ApiKey: SecretText)
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);
diff --git a/microsoft/knowledge/security/secrettext-for-credentials.md b/microsoft/knowledge/security/secrettext-for-credentials.md
index 17fec22..26a2511 100644
--- a/microsoft/knowledge/security/secrettext-for-credentials.md
+++ b/microsoft/knowledge/security/secrettext-for-credentials.md
@@ -1,5 +1,5 @@
---
-bc-version: [all]
+bc-version: [23..]
domain: security
keywords: [secrettext, credentials, api-key, token, debugger, unwrap]
technologies: [al]
@@ -15,7 +15,7 @@ application-area: [all]
## 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`.
+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 HTTP header or URI). Never round-trip through `Text`. On BC 24 and later, use the `SecretText` overload of `IsolatedStorage.Get` when retrieving stored secrets. See sample: `secrettext-for-credentials.good.al`.
## Anti Pattern
diff --git a/microsoft/knowledge/security/secrettext-with-httpclient.bad.al b/microsoft/knowledge/security/secrettext-with-httpclient.bad.al
index 6ddb883..6b11a4c 100644
--- a/microsoft/knowledge/security/secrettext-with-httpclient.bad.al
+++ b/microsoft/knowledge/security/secrettext-with-httpclient.bad.al
@@ -1,23 +1,23 @@
codeunit 50210 "Sec Sample SecretHttp Bad"
{
- procedure CallApiWithSecretInUri(ApiKey: SecretText)
+ procedure CallApiWithSecretInUri(ApiKey: Text)
var
HttpClient: HttpClient;
Response: HttpResponseMessage;
RequestUri: Text;
begin
- RequestUri := 'https://api.example.com/data?key=' + ApiKey.Unwrap();
+ RequestUri := StrSubstNo('https://api.example.com/data?key=%1', ApiKey);
HttpClient.Get(RequestUri, Response);
end;
- procedure CallApiWithBearer(BearerToken: SecretText)
+ procedure CallApiWithAccessToken(AccessToken: Text)
var
HttpClient: HttpClient;
Response: HttpResponseMessage;
Headers: HttpHeaders;
begin
Headers := HttpClient.DefaultRequestHeaders();
- Headers.Add('Authorization', 'Bearer ' + BearerToken.Unwrap());
+ Headers.Add('Authorization', StrSubstNo('Token %1', AccessToken));
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
index 50f0e31..e552512 100644
--- a/microsoft/knowledge/security/secrettext-with-httpclient.good.al
+++ b/microsoft/knowledge/security/secrettext-with-httpclient.good.al
@@ -3,26 +3,32 @@ codeunit 50209 "Sec Sample SecretHttp Good"
procedure CallApiWithSecretUri(ApiKey: SecretText)
var
HttpClient: HttpClient;
+ Request: HttpRequestMessage;
Response: HttpResponseMessage;
SecretUri: SecretText;
begin
SecretUri := SecretStrSubstNo('https://api.example.com/data?key=%1', ApiKey);
- HttpClient.SetSecretRequestUri(SecretUri);
- HttpClient.Get('', Response);
+ Request.Method := 'GET';
+ Request.SetSecretRequestUri(SecretUri);
+ HttpClient.Send(Request, Response);
end;
- procedure CallApiWithBearer(BearerToken: SecretText)
+ procedure CallApiWithAccessToken(AccessToken: SecretText)
var
HttpClient: HttpClient;
+ Request: HttpRequestMessage;
Response: HttpResponseMessage;
Headers: HttpHeaders;
AuthHeader: SecretText;
+ AuthorizationHeaderMissingErr: Label 'Authorization header missing.';
begin
- AuthHeader := SecretStrSubstNo('Bearer %1', BearerToken);
- Headers := HttpClient.DefaultRequestHeaders();
+ Request.Method := 'GET';
+ Request.SetRequestUri('https://api.example.com/data');
+ Request.GetHeaders(Headers);
+ AuthHeader := SecretStrSubstNo('Token %1', AccessToken);
Headers.Add('Authorization', AuthHeader);
if not Headers.ContainsSecret('Authorization') then
- Error('Authorization header missing');
- HttpClient.Get('https://api.example.com/data', Response);
+ Error(AuthorizationHeaderMissingErr);
+ HttpClient.Send(Request, Response);
end;
}
diff --git a/microsoft/knowledge/security/secrettext-with-httpclient.md b/microsoft/knowledge/security/secrettext-with-httpclient.md
index f8be895..dc67e3e 100644
--- a/microsoft/knowledge/security/secrettext-with-httpclient.md
+++ b/microsoft/knowledge/security/secrettext-with-httpclient.md
@@ -1,5 +1,5 @@
---
-bc-version: [all]
+bc-version: [23..]
domain: security
keywords: [secrettext, httpclient, setsecretrequesturi, containssecret, headers, http]
technologies: [al]
@@ -7,16 +7,16 @@ countries: [w1]
application-area: [all]
---
-# Use the SecretText-aware HttpClient surface for secrets in requests
+# Set secret request URIs on HttpRequestMessage
## 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.
+The secret URI API belongs to `HttpRequestMessage`, not `HttpClient`. `HttpRequestMessage.SetSecretRequestUri(SecretText)` keeps a credential-bearing URI protected, and the prepared request is sent with `HttpClient.Send`. Companion APIs also accept `SecretText`, including `HttpHeaders.Add` for authorization headers and `HttpContent.WriteFrom` for secret request bodies.
## 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`.
+Compose a secret URI with `SecretStrSubstNo`, call `Request.SetSecretRequestUri(SecretUri)`, set the request method, and send the request with `HttpClient.Send(Request, Response)`. For authorization, get the request headers, add a `SecretText` value, and use `ContainsSecret` when checking for that 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`.
+Holding a credential in `Text`, interpolating it with `StrSubstNo` or concatenation, and passing that plain text to `HttpClient.Get` or `HttpHeaders.Add`. The secret-aware request and header APIs remove the need to materialize the value as `Text`. See sample: `secrettext-with-httpclient.bad.al`.
diff --git a/microsoft/knowledge/security/validatetablerelation-false-on-user-input.good.al b/microsoft/knowledge/security/validatetablerelation-false-on-user-input.good.al
index 9a49bc3..ae414db 100644
--- a/microsoft/knowledge/security/validatetablerelation-false-on-user-input.good.al
+++ b/microsoft/knowledge/security/validatetablerelation-false-on-user-input.good.al
@@ -2,24 +2,21 @@ 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])
+ field(50223; "External Customer Ref"; Code[50])
{
TableRelation = Customer."No.";
ValidateTableRelation = false;
+ TestTableRelation = false;
+
trigger OnValidate()
var
- Customer: Record Customer;
+ InvalidExternalReferenceErr: Label 'The external customer reference must not contain spaces.';
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");
+ "External Customer Ref" := CopyStr(
+ UpperCase(DelChr("External Customer Ref", '<>', ' ')),
+ 1, MaxStrLen("External Customer Ref"));
+ if StrPos("External Customer Ref", ' ') > 0 then
+ Error(InvalidExternalReferenceErr);
end;
}
}
diff --git a/microsoft/knowledge/security/validatetablerelation-false-on-user-input.md b/microsoft/knowledge/security/validatetablerelation-false-on-user-input.md
index 275587c..d143ea5 100644
--- a/microsoft/knowledge/security/validatetablerelation-false-on-user-input.md
+++ b/microsoft/knowledge/security/validatetablerelation-false-on-user-input.md
@@ -7,16 +7,16 @@ countries: [w1]
application-area: [all]
---
-# Do not set ValidateTableRelation = false on user-editable fields
+# Handle free-form input when ValidateTableRelation is false
## 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.
+`ValidateTableRelation = false` intentionally lets a user keep free-form input even when it does not match `TableRelation`. This is supported for scenarios such as accepting a new vendor name and handling it in `OnValidate`. The risk is not the property itself; it is leaving downstream code to assume that every value identifies an existing related record.
## 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`.
+Keep the default validation when values must exist in the related table. When free-form values are intentional, set both `ValidateTableRelation = false` and `TestTableRelation = false`, then add compensating `OnValidate` logic that normalizes, validates, creates, or otherwise handles unmatched input. Document that downstream code must not assume the relation exists. 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`.
+`ValidateTableRelation = false` on a user-facing field with no intentional handling for unmatched values, or leaving `TestTableRelation = true` so database relation tests reject values the UI deliberately accepts. See sample: `validatetablerelation-false-on-user-input.bad.al`.
diff --git a/microsoft/knowledge/style/abouttitle-abouttext-teaching-tips.md b/microsoft/knowledge/style/abouttitle-abouttext-teaching-tips.md
index f72b959..edefcb6 100644
--- a/microsoft/knowledge/style/abouttitle-abouttext-teaching-tips.md
+++ b/microsoft/knowledge/style/abouttitle-abouttext-teaching-tips.md
@@ -1,5 +1,5 @@
---
-bc-version: [all]
+bc-version: [21..]
domain: style
keywords: [abouttitle, abouttext, teaching-tip, onboarding, page]
technologies: [al]
diff --git a/microsoft/knowledge/style/applicationarea-required-on-page-controls.bad.al b/microsoft/knowledge/style/applicationarea-required-on-page-controls.bad.al
new file mode 100644
index 0000000..8da7777
--- /dev/null
+++ b/microsoft/knowledge/style/applicationarea-required-on-page-controls.bad.al
@@ -0,0 +1,25 @@
+page 50375 "Sample App Area Bad"
+{
+ PageType = Card;
+ SourceTable = Customer;
+ layout
+ {
+ area(Content)
+ {
+ group(General)
+ {
+ // Anti-pattern: no ApplicationArea. AS0062 flags this control,
+ // and it is silently hidden in the Web client for profiles whose
+ // enabled areas do not already cover it.
+ field("No."; Rec."No.")
+ {
+ ToolTip = 'Specifies the number that identifies the customer.';
+ }
+ field(Name; Rec.Name)
+ {
+ ToolTip = 'Specifies the customer''s name.';
+ }
+ }
+ }
+ }
+}
diff --git a/microsoft/knowledge/style/applicationarea-required-on-page-controls.good.al b/microsoft/knowledge/style/applicationarea-required-on-page-controls.good.al
new file mode 100644
index 0000000..d344337
--- /dev/null
+++ b/microsoft/knowledge/style/applicationarea-required-on-page-controls.good.al
@@ -0,0 +1,40 @@
+page 50374 "Sample App Area 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(Name; Rec.Name)
+ {
+ ApplicationArea = All;
+ ToolTip = 'Specifies the customer''s name.';
+ }
+ }
+ }
+ }
+ actions
+ {
+ area(Processing)
+ {
+ action(Refresh)
+ {
+ ApplicationArea = All;
+ ToolTip = 'Reloads the current record.';
+
+ trigger OnAction()
+ begin
+ CurrPage.Update(false);
+ end;
+ }
+ }
+ }
+}
diff --git a/microsoft/knowledge/style/applicationarea-required-on-page-controls.md b/microsoft/knowledge/style/applicationarea-required-on-page-controls.md
new file mode 100644
index 0000000..606e08a
--- /dev/null
+++ b/microsoft/knowledge/style/applicationarea-required-on-page-controls.md
@@ -0,0 +1,28 @@
+---
+bc-version: [all]
+domain: style
+keywords: [application-area, page-control, as0062, appsourcecop, hidden-control, web-client]
+technologies: [al]
+countries: [w1]
+application-area: [all]
+---
+
+# Every page control needs an `ApplicationArea` (AppSourceCop AS0062)
+
+## Description
+
+A field control on a page or pageextension that has no `ApplicationArea` property is silently hidden in the Web client for every profile whose enabled application areas do not cover it. There is no error and no warning at runtime β the field simply does not appear, which reads as data loss to the user. AppSourceCop AS0062 flags any page control or action that is missing the `ApplicationArea` property, and AppSource technical validation rejects the app until it is set.
+
+Set the property to an area the app actually enables. `All` makes the control visible under every profile and is the common default; if the app declares narrower areas in `app.json`, use one of those. The property applies to field controls and to actions. This is a sibling concern to `caption-required-on-page-fields.md` and `tooltip-required-on-page-fields.md`; note that the ToolTip requirement is the separate CodeCop rule AA0218, not AS0062.
+
+## Best Practice
+
+Every field control and action carries `ApplicationArea = All;` (or a declared area of the app). The value is set once per control and keeps the control visible in the Web client.
+
+See sample: `applicationarea-required-on-page-controls.good.al`.
+
+## Anti Pattern
+
+A field control with no `ApplicationArea`. AS0062 flags it, and the control is invisible in the Web client for any profile that does not already enable a matching area.
+
+See sample: `applicationarea-required-on-page-controls.bad.al`.
diff --git a/microsoft/knowledge/style/this-keyword-in-codeunits.md b/microsoft/knowledge/style/this-keyword-in-codeunits.md
index ffcf5a1..b38cc24 100644
--- a/microsoft/knowledge/style/this-keyword-in-codeunits.md
+++ b/microsoft/knowledge/style/this-keyword-in-codeunits.md
@@ -1,5 +1,5 @@
---
-bc-version: [all]
+bc-version: [25..]
domain: style
keywords: [this, codeunit, self-reference, aa0248, scope]
technologies: [al]
diff --git a/microsoft/knowledge/testing/asserterror-needs-expectederror-and-code.bad.al b/microsoft/knowledge/testing/asserterror-needs-expectederror-and-code.bad.al
new file mode 100644
index 0000000..26b0ee4
--- /dev/null
+++ b/microsoft/knowledge/testing/asserterror-needs-expectederror-and-code.bad.al
@@ -0,0 +1,18 @@
+codeunit 50409 "Test AssertError Bad"
+{
+ Subtype = Test;
+
+ [Test]
+ procedure BlankNameIsRejectedWithSpecificError()
+ var
+ Customer: Record Customer;
+ begin
+ Customer.Init();
+ Customer.Name := '';
+
+ // Bare asserterror: passes if ANY error is raised. A relation error,
+ // a permission error, or a typo elsewhere would all satisfy it β so
+ // this never proves the blank-name guard is the thing that fired.
+ asserterror Customer.TestField(Name);
+ end;
+}
diff --git a/microsoft/knowledge/testing/asserterror-needs-expectederror-and-code.good.al b/microsoft/knowledge/testing/asserterror-needs-expectederror-and-code.good.al
new file mode 100644
index 0000000..fcbcfe6
--- /dev/null
+++ b/microsoft/knowledge/testing/asserterror-needs-expectederror-and-code.good.al
@@ -0,0 +1,26 @@
+codeunit 50408 "Test AssertError Good"
+{
+ Subtype = Test;
+
+ [Test]
+ procedure BlankNameIsRejectedWithSpecificError()
+ var
+ Customer: Record Customer;
+ begin
+ Customer.Init();
+ Customer.Name := '';
+
+ // [WHEN] a mandatory field is blank
+ asserterror Customer.TestField(Name);
+
+ // [THEN] verify the SPECIFIC failure through a reusable Library helper
+ // instead of hardcoding the localized message and the 'TestField' code.
+ // ExpectedTestFieldError centralizes that knowledge, so the test keeps
+ // working when the caption or code changes; FieldCaption avoids pinning
+ // the field name as a literal.
+ Assert.ExpectedTestFieldError(Customer.FieldCaption(Name), '');
+ end;
+
+ var
+ Assert: Codeunit "Library Assert";
+}
diff --git a/microsoft/knowledge/testing/asserterror-needs-expectederror-and-code.md b/microsoft/knowledge/testing/asserterror-needs-expectederror-and-code.md
new file mode 100644
index 0000000..0dee645
--- /dev/null
+++ b/microsoft/knowledge/testing/asserterror-needs-expectederror-and-code.md
@@ -0,0 +1,26 @@
+---
+bc-version: [all]
+domain: testing
+keywords: [asserterror, expectederror, expectederrorcode, negative-test, error-code]
+technologies: [al]
+countries: [w1]
+application-area: [all]
+---
+
+# Pin asserterror to a specific error with ExpectedError and ExpectedErrorCode
+
+## Description
+
+`asserterror` passes when the guarded statement raises any error at all. That is too permissive for a negative test: a typo, a missing setup record, or a permission failure all raise errors, so a bare `asserterror` can go green while never exercising the rule it claims to verify β false confidence that the validation works. Constrain it. `Assert.ExpectedError(text)` checks the message of the error that was actually raised, and `Assert.ExpectedErrorCode(code)` checks its error code. Together they assert that the specific failure occurred, turning "something went wrong" into "the right thing went wrong for the right reason".
+
+## Best Practice
+
+Follow every `asserterror` with a verification of the error it expects, and prefer the reusable `Library Assert` helpers over hardcoded literals. For a mandatory-field check, `Assert.ExpectedTestFieldError(FieldCaption, ExpectedValue)` encapsulates both the message and the `TestField` code, so the test survives caption or code changes and does not repeat that knowledge in every method. For other errors, pair `Assert.ExpectedError` with a stable substring β ideally a shared `Label`, not an inline sentence β and, where known, `Assert.ExpectedErrorCode`. When a needed check is missing from the shared library, extend `Library Assert` (or your own assert library) with a helper rather than hardcoding message text and codes across tests; matching on a code or an invariant fragment keeps the test from going blind to the wrong error when a caption is localized.
+
+See sample: `asserterror-needs-expectederror-and-code.good.al`.
+
+## Anti Pattern
+
+`asserterror DoInvalid();` with nothing after it. The test asserts only that the call failed somehow; swap the validation for a different bug and the test still passes, certifying a guard that may no longer fire. A negative test that cannot tell one error from another verifies almost nothing.
+
+See sample: `asserterror-needs-expectederror-and-code.bad.al`.
diff --git a/microsoft/knowledge/testing/transactionmodel-attribute-governs-test-transactions.bad.al b/microsoft/knowledge/testing/transactionmodel-attribute-governs-test-transactions.bad.al
index c30ae3b..4b5a26d 100644
--- a/microsoft/knowledge/testing/transactionmodel-attribute-governs-test-transactions.bad.al
+++ b/microsoft/knowledge/testing/transactionmodel-attribute-governs-test-transactions.bad.al
@@ -5,6 +5,23 @@ codeunit 50154 "Test Sample TransModel Bad"
[Test]
[TransactionModel(TransactionModel::AutoRollback)]
procedure TestPostingRoutineAutoRollback()
+ var
+ PostingRoutine: Codeunit "Posting Routine Commit Bad";
begin
+ // Runtime error: AutoRollback forbids the Commit reached below.
+ PostingRoutine.PostCustomer();
+ end;
+}
+
+codeunit 50156 "Posting Routine Commit Bad"
+{
+ procedure PostCustomer()
+ var
+ Customer: Record Customer;
+ begin
+ Customer.Init();
+ Customer."No." := 'T-BADCOMMIT';
+ Customer.Insert(true);
+ Commit();
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
index f977a94..7a19a61 100644
--- a/microsoft/knowledge/testing/transactionmodel-attribute-governs-test-transactions.good.al
+++ b/microsoft/knowledge/testing/transactionmodel-attribute-governs-test-transactions.good.al
@@ -16,6 +16,22 @@ codeunit 50153 "Test Sample TransModel Good"
[Test]
[TransactionModel(TransactionModel::AutoCommit)]
procedure TestLogicThatCommitsInternally()
+ var
+ PostingRoutine: Codeunit "Posting Routine With Commit";
begin
+ PostingRoutine.PostCustomer();
+ end;
+}
+
+codeunit 50155 "Posting Routine With Commit"
+{
+ procedure PostCustomer()
+ var
+ Customer: Record Customer;
+ begin
+ Customer.Init();
+ Customer."No." := 'T-COMMIT';
+ Customer.Insert(true);
+ Commit();
end;
}
diff --git a/microsoft/knowledge/testing/transactionmodel-attribute-governs-test-transactions.md b/microsoft/knowledge/testing/transactionmodel-attribute-governs-test-transactions.md
index 084fccd..ab89a96 100644
--- a/microsoft/knowledge/testing/transactionmodel-attribute-governs-test-transactions.md
+++ b/microsoft/knowledge/testing/transactionmodel-attribute-governs-test-transactions.md
@@ -15,7 +15,7 @@ application-area: [all]
## 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.
+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 make the test exercise that commit path. Pair the test 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.
See sample: `transactionmodel-attribute-governs-test-transactions.good.al`.
diff --git a/microsoft/knowledge/testing/ui-handlers-in-tests.bad.al b/microsoft/knowledge/testing/ui-handlers-in-tests.bad.al
new file mode 100644
index 0000000..1ecf47d
--- /dev/null
+++ b/microsoft/knowledge/testing/ui-handlers-in-tests.bad.al
@@ -0,0 +1,43 @@
+codeunit 50401 "Test UI Handlers Bad"
+{
+ Subtype = Test;
+
+ // Several wiring mistakes, each of which fails at runtime rather than as a
+ // clean assertion the reviewer can read:
+ // * A UI call with no listed handler -> "unhandled UI" abort (the Message
+ // below has no handler).
+ // * The mirror mistake, listing a handler the path never hits, instead
+ // fails with "handler function was not executed".
+ // * A handler that hardcodes its answer and asserts inline, with no
+ // enqueue/dequeue -> nothing proves the RIGHT dialog fired the RIGHT
+ // number of times, and a failed inline assert can be swallowed by the
+ // calling UI operation.
+ [Test]
+ [HandlerFunctions('ConfirmHandler')]
+ procedure PostDocumentConfirmsAndMessages()
+ begin
+ // No Initialize(): a value leaked by an earlier test corrupts this one.
+ RunPostingThatConfirmsAndMessages();
+ // No AssertEmpty(): a missing or extra dialog goes unnoticed.
+ end;
+
+ local procedure RunPostingThatConfirmsAndMessages()
+ begin
+ // Raises a Confirm AND a Message, but only ConfirmHandler is listed:
+ // the Message has nothing to intercept it -> unhandled-UI runtime abort.
+ if Confirm('Post this document?', false) then
+ Message('Posting completed.');
+ end;
+
+ [ConfirmHandler]
+ procedure ConfirmHandler(Question: Text[1024]; var Reply: Boolean)
+ begin
+ // Hardcoded expectation and hardcoded reply. If the wrong dialog fires,
+ // this inline assert may never surface as the test's verdict.
+ Assert.AreEqual('Post this document?', Question, 'Wrong confirm.');
+ Reply := true;
+ end;
+
+ var
+ Assert: Codeunit "Library Assert";
+}
diff --git a/microsoft/knowledge/testing/ui-handlers-in-tests.good.al b/microsoft/knowledge/testing/ui-handlers-in-tests.good.al
new file mode 100644
index 0000000..f955477
--- /dev/null
+++ b/microsoft/knowledge/testing/ui-handlers-in-tests.good.al
@@ -0,0 +1,57 @@
+codeunit 50400 "Test UI Handlers Good"
+{
+ Subtype = Test;
+
+ [Test]
+ [HandlerFunctions('ConfirmHandler,PostMessageHandler')]
+ procedure PostDocumentConfirmsAndMessages()
+ begin
+ Initialize();
+
+ // [GIVEN] the test enqueues, in interaction order, what each handler
+ // will see and how it should answer: the Confirm's expected
+ // question plus the reply to return, then the expected Message.
+ LibraryVariableStorage.Enqueue('Post this document?'); // expected question (substring)
+ LibraryVariableStorage.Enqueue(true); // reply ConfirmHandler returns
+ LibraryVariableStorage.Enqueue('Posting completed.'); // expected message (substring)
+
+ // [WHEN] the code under test raises the Confirm and then the Message
+ RunPostingThatConfirmsAndMessages();
+
+ // [THEN] every enqueued expectation was consumed exactly once
+ LibraryVariableStorage.AssertEmpty();
+ end;
+
+ local procedure Initialize()
+ begin
+ // Clear leftover values so a value leaked by an earlier test cannot
+ // cascade into this one.
+ LibraryVariableStorage.Clear();
+ end;
+
+ local procedure RunPostingThatConfirmsAndMessages()
+ begin
+ // Stands in for the production routine that confirms, then messages.
+ if Confirm('Post this document?', false) then
+ Message('Posting completed.');
+ end;
+
+ [ConfirmHandler]
+ procedure ConfirmHandler(Question: Text[1024]; var Reply: Boolean)
+ begin
+ // Verify the RIGHT dialog fired (substring match), then return the
+ // reply the test enqueued for it.
+ Assert.ExpectedConfirm(LibraryVariableStorage.DequeueText(), Question);
+ Reply := LibraryVariableStorage.DequeueBoolean();
+ end;
+
+ [MessageHandler]
+ procedure PostMessageHandler(Message: Text[1024])
+ begin
+ Assert.ExpectedMessage(LibraryVariableStorage.DequeueText(), Message);
+ end;
+
+ var
+ Assert: Codeunit "Library Assert";
+ LibraryVariableStorage: Codeunit "Library - Variable Storage";
+}
diff --git a/microsoft/knowledge/testing/ui-handlers-in-tests.md b/microsoft/knowledge/testing/ui-handlers-in-tests.md
new file mode 100644
index 0000000..338e9ec
--- /dev/null
+++ b/microsoft/knowledge/testing/ui-handlers-in-tests.md
@@ -0,0 +1,28 @@
+---
+bc-version: [all]
+domain: testing
+keywords: [handler, handlerfunctions, confirm, message, strmenu, variable-storage, enqueue, unhandled-ui]
+technologies: [al]
+countries: [w1]
+application-area: [all]
+---
+
+# Wire and verify UI handlers with enqueue-driven expectations
+
+## Description
+
+A test runs headless: there is no interactive user to answer a dialog. Every UI call the executed path raises β `Confirm`, `Message`, error dialogs, `Page.Run`/`RunModal`, `Report.Run`/`RunModal`, request pages, `StrMenu`, `Notification.Send` β must be intercepted by a handler carrying the matching attribute (`[ConfirmHandler]`, `[MessageHandler]`, `[StrMenuHandler]`, `[ModalPageHandler]`, β¦) and named in the method's `[HandlerFunctions(...)]`. The list is a two-sided contract: raise a UI call with no listed handler and the platform aborts with an *unhandled UI* error; list a handler the path never hits and it fails with *"handler function was not executed"*. Both are runtime failures β the test never reaches its verdict, so a reviewer sees an infrastructure error instead of a result on the behavior under test.
+
+Getting the handler *present* is only half the job; the handler must also verify the *right* dialog fired the *right* number of times. Do that by driving handlers from the test, not by hardcoding answers inside them.
+
+## Best Practice
+
+Make the test own the expectations and the handlers consume them. Before acting, the test `Enqueue`s β in interaction order β the expected text (a stable substring) and any reply each handler must return. The handler `Dequeue`s the expected text, verifies it with the purpose-built asserts (`Assert.ExpectedMessage`, `Assert.ExpectedConfirm`, `Assert.ExpectedStrMenu` β which match on a fragment, not the full localized caption), then `Dequeue`s and returns its reply. Finish the test body with `LibraryVariableStorage.AssertEmpty` to prove every enqueued interaction fired exactly once, and start each test with an `Initialize` that calls `LibraryVariableStorage.Clear` so a value leaked by an earlier test cannot cascade. List in `[HandlerFunctions]` precisely the handlers the scenario triggers β no superset "just in case", no subset that happens to work today.
+
+See sample: `ui-handlers-in-tests.good.al`.
+
+## Anti Pattern
+
+Omitting a handler for a UI call the path raises (unhandled-UI abort), padding the list with a handler the path never reaches ("handler function was not executed"), or writing handlers that hardcode their answer and assert inline with no enqueue/dequeue. The last is the subtle one: nothing proves the correct dialog fired the expected number of times, and an inline assertion that fails inside a handler can be swallowed by the calling UI operation, leaving the suite green while the behavior is broken. Skipping `Initialize`/`AssertEmpty` hides both a leaked queue and a missing or extra dialog.
+
+See sample: `ui-handlers-in-tests.bad.al`.
diff --git a/microsoft/knowledge/testing/use-library-codeunits-for-test-fixtures.bad.al b/microsoft/knowledge/testing/use-library-codeunits-for-test-fixtures.bad.al
new file mode 100644
index 0000000..cf7805a
--- /dev/null
+++ b/microsoft/knowledge/testing/use-library-codeunits-for-test-fixtures.bad.al
@@ -0,0 +1,25 @@
+codeunit 50411 "Test Library Fixtures Bad"
+{
+ Subtype = Test;
+
+ [Test]
+ procedure OrderUsesHandRolledFixtures()
+ var
+ Customer: Record Customer;
+ SalesHeader: Record "Sales Header";
+ begin
+ // Hand-rolled customer: a chosen "No." with no number-series entry and
+ // none of the mandatory fields a real customer carries. Bypasses the
+ // setup production code assumes and breaks when the schema adds a
+ // required field this test does not set.
+ Customer.Init();
+ Customer."No." := 'X';
+ Customer.Insert();
+
+ SalesHeader.Init();
+ SalesHeader."Document Type" := SalesHeader."Document Type"::Order;
+ SalesHeader."No." := 'SO-X';
+ SalesHeader.Validate("Sell-to Customer No.", Customer."No.");
+ SalesHeader.Insert(true);
+ end;
+}
diff --git a/microsoft/knowledge/testing/use-library-codeunits-for-test-fixtures.good.al b/microsoft/knowledge/testing/use-library-codeunits-for-test-fixtures.good.al
new file mode 100644
index 0000000..66d4b32
--- /dev/null
+++ b/microsoft/knowledge/testing/use-library-codeunits-for-test-fixtures.good.al
@@ -0,0 +1,28 @@
+codeunit 50410 "Test Library Fixtures Good"
+{
+ Subtype = Test;
+
+ [Test]
+ procedure OrderUsesLibraryCreatedFixtures()
+ var
+ Customer: Record Customer;
+ Item: Record Item;
+ SalesHeader: Record "Sales Header";
+ SalesLine: Record "Sales Line";
+ begin
+ // Library codeunits create valid parents: number series, mandatory
+ // fields and table relations are all handled for you.
+ LibrarySales.CreateCustomer(Customer);
+ LibraryInventory.CreateItem(Item);
+ LibrarySales.CreateSalesHeader(SalesHeader, SalesHeader."Document Type"::Order, Customer."No.");
+ LibrarySales.CreateSalesLine(SalesLine, SalesHeader, SalesLine.Type::Item, Item."No.", LibraryRandom.RandInt(10));
+
+ Assert.AreEqual(Customer."No.", SalesHeader."Sell-to Customer No.", 'Header should use the created customer.');
+ end;
+
+ var
+ Assert: Codeunit "Library Assert";
+ LibrarySales: Codeunit "Library - Sales";
+ LibraryInventory: Codeunit "Library - Inventory";
+ LibraryRandom: Codeunit "Library - Random";
+}
diff --git a/microsoft/knowledge/testing/use-library-codeunits-for-test-fixtures.md b/microsoft/knowledge/testing/use-library-codeunits-for-test-fixtures.md
new file mode 100644
index 0000000..270a146
--- /dev/null
+++ b/microsoft/knowledge/testing/use-library-codeunits-for-test-fixtures.md
@@ -0,0 +1,26 @@
+---
+bc-version: [all]
+domain: testing
+keywords: [library-codeunits, fixtures, test-data, number-series, prerequisite]
+technologies: [al]
+countries: [w1]
+application-area: [all]
+---
+
+# Build fixtures with the test Library codeunits, not hand-rolled Init/Insert
+
+## Description
+
+BC ships a layer of test Library codeunits β `LibrarySales`, `LibraryPurchase`, `LibraryERM`, `LibraryInventory`, `LibraryRandom` and many more β whose job is to create valid records. `CreateCustomer` assigns a number from the customer number series, fills the mandatory fields, and satisfies the table relations the platform enforces; `CreateItem` does the same for items. Hand-rolling `Customer.Init`/`Customer.Insert` with invented values skips the number series and any field a future app version adds as mandatory, so the fixture is invalid the moment it is created and rots silently as the schema evolves. The library codeunits also encode fixture *ordering*: because a `TableRelation` field is checked on `Validate` and `Insert(true)`, every parent a foreign key points to must already exist when the dependent record is built. Assemble fixtures top-down β customer and item before the sales line that references them β or the relation check aborts the test at runtime with a data error rather than an assertion. Prefer the Library codeunits for prerequisite data: they encode the setup the platform requires and are maintained alongside the base app.
+
+## Best Practice
+
+Reach for the matching Library codeunit before writing manual record setup: `LibrarySales.CreateCustomer`, `LibrarySales.CreateSalesHeader`/`CreateSalesLine`, `LibraryInventory.CreateItem`, `LibraryERM.CreateGLAccount`, and `LibraryRandom.RandInt`/`RandDec` for values. Create the prerequisite parents first and reference their primary keys from dependent records, and `Validate` the foreign-key field so the `TableRelation` β and any field-validation logic β runs exactly as it would in production. Pass the records they return into the code under test. The fixtures stay valid across upgrades because the library β not your test β owns the knowledge of what a well-formed record requires.
+
+See sample: `use-library-codeunits-for-test-fixtures.good.al`.
+
+## Anti Pattern
+
+`Customer.Init(); Customer."No." := 'X'; Customer.Insert();` β a record with a hand-picked primary key, no number-series entry, and none of the mandatory fields a real customer needs. It compiles and may even insert, but it bypasses setup the production code assumes, and it breaks the first time the schema gains a required field the test does not know about.
+
+See sample: `use-library-codeunits-for-test-fixtures.bad.al`.
diff --git a/microsoft/knowledge/ui/control-addin-package-resource-ajax-needs-withcredentials.bad.js b/microsoft/knowledge/ui/control-addin-package-resource-ajax-needs-withcredentials.bad.js
new file mode 100644
index 0000000..d36c363
--- /dev/null
+++ b/microsoft/knowledge/ui/control-addin-package-resource-ajax-needs-withcredentials.bad.js
@@ -0,0 +1,3 @@
+function loadPackagedTemplate(url) {
+ return $.get(url).done(renderTemplate);
+}
diff --git a/microsoft/knowledge/ui/control-addin-package-resource-ajax-needs-withcredentials.good.js b/microsoft/knowledge/ui/control-addin-package-resource-ajax-needs-withcredentials.good.js
new file mode 100644
index 0000000..781c23d
--- /dev/null
+++ b/microsoft/knowledge/ui/control-addin-package-resource-ajax-needs-withcredentials.good.js
@@ -0,0 +1,8 @@
+function loadPackagedTemplate(url) {
+ return $.ajax({
+ url: url,
+ xhrFields: {
+ withCredentials: true
+ }
+ }).done(renderTemplate);
+}
diff --git a/microsoft/knowledge/ui/control-addin-package-resource-ajax-needs-withcredentials.md b/microsoft/knowledge/ui/control-addin-package-resource-ajax-needs-withcredentials.md
new file mode 100644
index 0000000..d5edd39
--- /dev/null
+++ b/microsoft/knowledge/ui/control-addin-package-resource-ajax-needs-withcredentials.md
@@ -0,0 +1,30 @@
+---
+bc-version: [all]
+domain: ui
+keywords: [control-add-in, packaged-resource, ajax, withcredentials, xhrfields, jquery]
+technologies: [javascript]
+countries: [w1]
+application-area: [all]
+---
+
+# Load packaged control add-in resources with credentialed AJAX
+
+## Description
+
+JavaScript in a Business Central control add-in can load a static resource from its extension package with AJAX, but the request needs the Business Central context and cookies. Set `xhrFields.withCredentials = true`; shorthand calls such as `$.get` omit that setting and can work during development yet fail in production.
+
+## Best Practice
+
+Use an AJAX form that explicitly enables `withCredentials` whenever a control add-in requests a packaged static resource. Keep this rule scoped to resources served from the add-in package; it is not generic advice to attach credentials to arbitrary external requests.
+
+See sample: `control-addin-package-resource-ajax-needs-withcredentials.good.js`.
+
+## Anti Pattern
+
+Using `$.get(url)` or an `XMLHttpRequest` without `withCredentials = true` to retrieve package content. The request can lack the context and cookies required by the Business Central service.
+
+See sample: `control-addin-package-resource-ajax-needs-withcredentials.bad.js`.
+
+## Source
+
+[Control add-in object: Loading static resources using AJAX requests](https://learn.microsoft.com/dynamics365/business-central/dev-itpro/developer/devenv-control-addin-object#loading-static-resources-using-ajax-requests).
diff --git a/microsoft/knowledge/ui/control-addin-throttle-al-calls-and-payload-size.bad.js b/microsoft/knowledge/ui/control-addin-throttle-al-calls-and-payload-size.bad.js
new file mode 100644
index 0000000..9bd6ebf
--- /dev/null
+++ b/microsoft/knowledge/ui/control-addin-throttle-al-calls-and-payload-size.bad.js
@@ -0,0 +1,8 @@
+function startSendingRows(rows) {
+ window.setInterval(() => {
+ Microsoft.Dynamics.NAV.InvokeExtensibilityMethod(
+ "StoreRows",
+ [JSON.stringify(rows)],
+ false);
+ }, 100);
+}
diff --git a/microsoft/knowledge/ui/control-addin-throttle-al-calls-and-payload-size.good.js b/microsoft/knowledge/ui/control-addin-throttle-al-calls-and-payload-size.good.js
new file mode 100644
index 0000000..d6815a8
--- /dev/null
+++ b/microsoft/knowledge/ui/control-addin-throttle-al-calls-and-payload-size.good.js
@@ -0,0 +1,74 @@
+const pendingChunks = [];
+let callInProgress = false;
+let transferHalted = false;
+
+function sendRows(rows, maxArgumentsBytes) {
+ if (transferHalted)
+ throw new Error("Retry or discard the failed chunk before sending more rows.");
+
+ const encoder = new TextEncoder();
+ const chunks = [];
+ let chunk = [];
+ const argumentBytes = (payload) =>
+ encoder.encode(JSON.stringify([payload])).length;
+
+ for (const row of rows) {
+ if (argumentBytes(JSON.stringify([row])) > maxArgumentsBytes)
+ throw new Error("A row exceeds the configured payload limit.");
+
+ const candidate = JSON.stringify([...chunk, row]);
+
+ if (argumentBytes(candidate) <= maxArgumentsBytes) {
+ chunk.push(row);
+ continue;
+ }
+
+ chunks.push(JSON.stringify(chunk));
+ chunk = [row];
+ }
+
+ if (chunk.length > 0)
+ chunks.push(JSON.stringify(chunk));
+
+ pendingChunks.push(...chunks);
+ sendNextChunk();
+}
+
+function sendNextChunk() {
+ if (callInProgress || pendingChunks.length === 0)
+ return;
+
+ callInProgress = true;
+ const payload = pendingChunks[0];
+ Microsoft.Dynamics.NAV.InvokeExtensibilityMethod(
+ "StoreRows",
+ [payload],
+ false,
+ () => {
+ pendingChunks.shift();
+ callInProgress = false;
+ sendNextChunk();
+ },
+ () => {
+ callInProgress = false;
+ transferHalted = true;
+ showTransferError();
+ });
+}
+
+function retryFailedChunk() {
+ if (!transferHalted)
+ return;
+
+ transferHalted = false;
+ sendNextChunk();
+}
+
+function discardFailedChunk() {
+ if (!transferHalted)
+ return;
+
+ pendingChunks.shift();
+ transferHalted = false;
+ sendNextChunk();
+}
diff --git a/microsoft/knowledge/ui/control-addin-throttle-al-calls-and-payload-size.md b/microsoft/knowledge/ui/control-addin-throttle-al-calls-and-payload-size.md
new file mode 100644
index 0000000..987eedb
--- /dev/null
+++ b/microsoft/knowledge/ui/control-addin-throttle-al-calls-and-payload-size.md
@@ -0,0 +1,30 @@
+---
+bc-version: [20..]
+domain: ui
+keywords: [control-add-in, invokeextensibilitymethod, success-callback, throttling, payload, reduced-functionality]
+technologies: [javascript]
+countries: [w1]
+application-area: [all]
+---
+
+# Serialize control add-in AL calls and keep payloads small
+
+## Description
+
+`InvokeExtensibilityMethod` crosses from a control add-in into the Business Central service. Repeated calls that outpace AL execution fill the communication channel, trigger reduced-functionality warnings, and can be queued, throttled, or rejected; an oversized single payload can also be rejected immediately. The success and error callbacks exist so the add-in can bound this traffic.
+
+## Best Practice
+
+Send byte-bounded chunks and invoke the next AL event only from the previous call's completion callback. Handle the error callback and stop until the caller explicitly retries or discards the failed chunk. There is no universal safe threshold, so measure the serialized argument array, reserve transport headroom below the server's `ClientServicesMaxUploadSize`, and reject an individual item that exceeds the configured budget.
+
+See sample: `control-addin-throttle-al-calls-and-payload-size.good.js`.
+
+## Anti Pattern
+
+Calling `InvokeExtensibilityMethod` on an interval without tracking completion, recursively creating intervals, or serializing an entire unbounded dataset into one call. These patterns can overwhelm the client-service channel or exceed the upload limit.
+
+See sample: `control-addin-throttle-al-calls-and-payload-size.bad.js`.
+
+## Source
+
+[Control add-in performance best practices](https://learn.microsoft.com/dynamics365/business-central/dev-itpro/developer/devenv-control-addin-bestpractices), [InvokeExtensibilityMethod](https://learn.microsoft.com/dynamics365/business-central/dev-itpro/developer/methods/devenv-invokeextensibility-method), and [control add-in resiliency](https://learn.microsoft.com/dynamics365/business-central/across-controladdin-resiliency).
diff --git a/community/knowledge/ui/default-descending-sort-on-historical-pages.bad.al b/microsoft/knowledge/ui/default-descending-sort-on-historical-pages.bad.al
similarity index 100%
rename from community/knowledge/ui/default-descending-sort-on-historical-pages.bad.al
rename to microsoft/knowledge/ui/default-descending-sort-on-historical-pages.bad.al
diff --git a/community/knowledge/ui/default-descending-sort-on-historical-pages.good.al b/microsoft/knowledge/ui/default-descending-sort-on-historical-pages.good.al
similarity index 100%
rename from community/knowledge/ui/default-descending-sort-on-historical-pages.good.al
rename to microsoft/knowledge/ui/default-descending-sort-on-historical-pages.good.al
diff --git a/community/knowledge/ui/default-descending-sort-on-historical-pages.md b/microsoft/knowledge/ui/default-descending-sort-on-historical-pages.md
similarity index 100%
rename from community/knowledge/ui/default-descending-sort-on-historical-pages.md
rename to microsoft/knowledge/ui/default-descending-sort-on-historical-pages.md
diff --git a/microsoft/knowledge/ui/fasttab-field-importance.md b/microsoft/knowledge/ui/fasttab-field-importance.md
new file mode 100644
index 0000000..f7de6dd
--- /dev/null
+++ b/microsoft/knowledge/ui/fasttab-field-importance.md
@@ -0,0 +1,18 @@
+---
+bc-version: [all]
+domain: ui
+keywords: [importance, promoted, additional, fasttab, show-more, summary-line, progressive-disclosure, field-visibility]
+technologies: [al]
+countries: [w1]
+application-area: [all]
+---
+# Set Field Importance To Drive FastTab Progressive Disclosure
+
+## Description
+A FastTab field's `Importance` property controls whether the field is visible immediately, hidden behind "Show more", or surfaced on the collapsed FastTab header summary line. The three values are `Standard` (the default, shown in the expanded FastTab), `Promoted` (also rendered on the FastTab header when the tab is collapsed), and `Additional` (hidden until the user clicks "Show more"). Misusing these values either clutters the summary line or buries fields users need on every transaction, so reviewers should treat `Importance` as a deliberate layout decision rather than an afterthought.
+
+## Best Practice
+Promote only the small set of identifying fields per FastTab that users must read at a glance without expanding β name, status, key amount β so the collapsed header summary line stays scannable. Leave the everyday working fields at `Standard`, and push rarely-touched fields (legacy compatibility fields, system timestamps, seldom-changed configuration) to `Additional`. Note that field-level `Importance = Promoted` is unrelated to action promotion on the page action bar; it governs FastTab field visibility only. Do not rely on initial expand or collapse state, which you cannot set programmatically and which the platform may personalize per user β design assuming any FastTab may be collapsed.
+
+## Anti Pattern
+Setting `Importance = Promoted` on most fields of a FastTab so "everything is important" defeats progressive disclosure: the collapsed summary line overflows and conveys nothing at a glance. The opposite failure is marking frequently edited fields `Additional`, forcing users to click "Show more" on every record. A detectable signal is a FastTab whose fields are nearly all `Promoted`, or a FastTab containing only `Additional` fields, which renders as an empty tab until expanded.
diff --git a/microsoft/knowledge/ui/page-background-tasks.md b/microsoft/knowledge/ui/page-background-tasks.md
new file mode 100644
index 0000000..8a1d3fa
--- /dev/null
+++ b/microsoft/knowledge/ui/page-background-tasks.md
@@ -0,0 +1,18 @@
+---
+bc-version: [all]
+domain: ui
+keywords: [enqueuebackgroundtask, async-calculation, child-session, factbox, cue-tile, onaftergetcurrrecord, responsive-page, read-only]
+technologies: [al]
+countries: [w1]
+application-area: [all]
+---
+# Offload Slow Read-Only Page Calculations To Background Tasks
+
+## Description
+Pages that compute statistics, aggregates, or external lookups inline block the page from rendering until the calculation finishes, producing a visible freeze on FactBoxes, cue tiles, and calculated fields. Business Central provides page background tasks: `CurrPage.EnqueueBackgroundTask` runs a dedicated codeunit in a read-only child session and returns values via `OnPageBackgroundTaskCompleted`, so the page opens immediately and fills in computed values as they arrive. This matters because users should never wait on a calculation they may not need. The mechanism has specific rules that are easy to get wrong, which is why it warrants an explicit pattern.
+
+## Best Practice
+Move any noticeable read-only computation off the synchronous render path into a background task. Enqueue from `OnAfterGetCurrRecord` so the task is tied to the currently focused record, and pass small payloads through the `Dictionary of [Text, Text]` input/output, converting types with `Format` and `Evaluate`. Keep each task focused on one value or a small related set rather than one large task, and show a placeholder until results land. Because tasks auto-cancel when the page closes, the record changes, or a same-ID task is re-enqueued, always supply sensible defaults and handle the timeout path in `OnPageBackgroundTaskError` β never let critical functionality depend on completion. For tests, drive the task synchronously with `RunPageBackgroundTask`.
+
+## Anti Pattern
+Enqueuing from `OnAfterGetRecord` on a list page fires the task for every row, and each cancels the instant the selection moves to the next row β pure wasted child-session churn; a reviewer spots `EnqueueBackgroundTask` called from `OnAfterGetRecord` (or from `OnOpenPage`, where the record context is not yet stable). The other tell is a task codeunit attempting a database write or `Modify`: background tasks run read-only and the write fails at runtime. Inline heavy calculation directly in `OnAfterGetCurrRecord` with no task at all is the baseline smell β it reintroduces the page freeze the feature exists to remove.
diff --git a/microsoft/knowledge/ui/prefer-actionref-syntax-for-promoted-actions.md b/microsoft/knowledge/ui/prefer-actionref-syntax-for-promoted-actions.md
new file mode 100644
index 0000000..b0e66f0
--- /dev/null
+++ b/microsoft/knowledge/ui/prefer-actionref-syntax-for-promoted-actions.md
@@ -0,0 +1,18 @@
+---
+bc-version: [21..]
+domain: ui
+keywords: [actionref, promoted-actions, area-promoted, promotedcategory, promotedonly, action-bar, legacy-syntax]
+technologies: [al]
+countries: [w1]
+application-area: [all]
+---
+# Promote Actions With The Modern `actionref` Syntax, Never The Legacy `Promoted` Properties
+
+## Description
+Business Central 2022 release wave 2 (v21) introduced the `area(Promoted)` block with `actionref` as the way to promote page actions, separating an action's definition from its promotion. The older approach set `Promoted`, `PromotedCategory`, `PromotedOnly`, and `PromotedIsBig` directly on each action. The two syntaxes cannot be mixed within a single page or page extension, and choosing the legacy one entangles definition with presentation, making the action bar harder to maintain and to extend.
+
+## Best Practice
+For new pages and page extensions, define actions in their normal `area`, then promote selected ones with `actionref` inside `area(Promoted)`, grouping them under explicit categories such as `Category_Process` and entity-named groups. This keeps each action defined once and referenced where it should appear, supports split buttons via `ShowAs`, and lets an extension promote a base action without redefining it. When extending a page, you may use modern syntax even if the base page used legacy properties (and vice versa) β the no-mixing rule is per-object, not per-dependency-tree.
+
+## Anti Pattern
+Setting `Promoted = true` (with `PromotedCategory`, `PromotedOnly`, or `PromotedIsBig`) on actions in new code, or attempting to combine those properties with an `area(Promoted)` block in the same object β the latter fails to compile. The reviewer signal is any `Promoted`-prefixed property on an action in a newly authored page or page extension; flag it and convert to `actionref` (VS Code offers an automated conversion). Note separately that once an action is promoted in a published app, removing the promotion is a breaking change (AS0031/AW0013), so promote conservatively rather than walking it back later.
diff --git a/microsoft/knowledge/ui/promoted-action-groups.md b/microsoft/knowledge/ui/promoted-action-groups.md
new file mode 100644
index 0000000..e2e8883
--- /dev/null
+++ b/microsoft/knowledge/ui/promoted-action-groups.md
@@ -0,0 +1,18 @@
+---
+bc-version: [21..]
+domain: ui
+keywords: [action-groups, area-promoted, actionref, showas, split-button, group-caption, navigate-group, entity-group]
+technologies: [al]
+countries: [w1]
+application-area: [all]
+---
+# Use Standard Promoted Action Group Names And Placements
+
+## Description
+Business Central ships a fixed vocabulary of promoted action groups, and users build muscle memory around where each kind of action lives. When you define `area(Promoted)` groups, reusing the standard caption and placement for a given action class makes the page feel native; inventing your own caption or putting an action in the wrong group forces every user to relearn your page. Frontier models tend to emit plausible-but-nonstandard captions (`Go To`, `Vendor Actions`, `Related`) instead of the established BC names, which is exactly what breaks cross-page consistency.
+
+## Best Practice
+Map each action to its conventional group and use the exact standard caption: `Home`/`Process` for data-modifying and workflow actions (entity/card/document pages use `Home`, lists and worksheets use `Process`); an entity-named group (`Customer`, `Item`, `Order`) for navigation tied to the current record (statistics, ledger entries, dimensions); `Navigate` for related pages that are useful regardless of the selected record; `Report` for printing and analysis; and the workflow groups `Posting`, `Release`, `Approve`, `Request Approval`, and `Prepare` for their respective document lifecycle actions. Standard guidance recommends `ShowAs = SplitButton` for `Posting` (Post / Post and Print / Preview) and `Release` (Release / Reopen), while the other common groups normally render as standard groups. Use a split button elsewhere only for closely related alternatives with an obvious primary action. The first enabled and visible action becomes the primary button, so place the expected default first and remember that extensions or personalization can reorder it. Within a common group keep the same action sequence you see on the matching base-app page (for example, mirror Sales Order for a sales document) so order stays predictable.
+
+## Anti Pattern
+Custom captions for what is really a standard group (`Vendor Actions` instead of the `Vendor` entity group, `Go To` instead of `Navigate`), posting or statistics actions dropped into the wrong group, or many tiny one-action groups that fragment the ribbon. The reviewer signal is an `area(Promoted)` block whose `group` captions do not match the base-application names for the same page type, or a split button whose actions are unrelated or lack an obvious primary operation.
diff --git a/microsoft/knowledge/ui/set-selection-filter-list-scope.bad.al b/microsoft/knowledge/ui/set-selection-filter-list-scope.bad.al
new file mode 100644
index 0000000..ff3ca99
--- /dev/null
+++ b/microsoft/knowledge/ui/set-selection-filter-list-scope.bad.al
@@ -0,0 +1,12 @@
+// Bad: SetSelectionFilter with cursor-only (no explicit multi-selection) produces
+// a primary key filter for just that one row. The codeunit receives only that row;
+// the rest of the visible list is silently skipped with no error raised.
+trigger OnAction()
+var
+ PriceListHeader: Record "Price List Header";
+ TempErrorMessage: Record "Error Message" temporary;
+ ProcessingCodeunit: Codeunit "My Batch Processor";
+begin
+ CurrPage.SetSelectionFilter(PriceListHeader);
+ ProcessingCodeunit.RunBatch(PriceListHeader, TempErrorMessage);
+end;
diff --git a/microsoft/knowledge/ui/set-selection-filter-list-scope.good.al b/microsoft/knowledge/ui/set-selection-filter-list-scope.good.al
new file mode 100644
index 0000000..e3a91af
--- /dev/null
+++ b/microsoft/knowledge/ui/set-selection-filter-list-scope.good.al
@@ -0,0 +1,14 @@
+// Good: check MarkedOnly before deciding which scope to process.
+// When MarkedOnly is false (cursor-only or Ctrl+A) fall back to Copy(Rec)
+// so every record visible in the page view is included.
+trigger OnAction()
+var
+ PriceListHeader: Record "Price List Header";
+ TempErrorMessage: Record "Error Message" temporary;
+ ProcessingCodeunit: Codeunit "My Batch Processor";
+begin
+ CurrPage.SetSelectionFilter(PriceListHeader);
+ if not PriceListHeader.MarkedOnly then
+ PriceListHeader.Copy(Rec);
+ ProcessingCodeunit.RunBatch(PriceListHeader, TempErrorMessage);
+end;
diff --git a/microsoft/knowledge/ui/set-selection-filter-list-scope.md b/microsoft/knowledge/ui/set-selection-filter-list-scope.md
new file mode 100644
index 0000000..513590c
--- /dev/null
+++ b/microsoft/knowledge/ui/set-selection-filter-list-scope.md
@@ -0,0 +1,28 @@
+---
+bc-version: [all]
+domain: ui
+keywords: [set-selection-filter, marked-only, list-page, bulk-action, batch-action, selection-scope, copy-rec]
+technologies: [al]
+countries: [w1]
+application-area: [all]
+---
+
+# Preserve list scope after `SetSelectionFilter`
+
+## Description
+
+`CurrPage.SetSelectionFilter(Rec)` behaves differently depending on whether the user explicitly multi-selected rows. When no rows are marked β the cursor is simply positioned on a row β the method writes a primary key filter for that single row and leaves `MarkedOnly` as false. When the user explicitly selected multiple rows, the method marks those records and sets `MarkedOnly` to true. A batch action that calls `SetSelectionFilter` and then passes the record directly to a processing codeunit will therefore silently restrict to one row whenever the user has not made an explicit selection, which is almost never the intended behaviour for an action labelled "Verify All" or "Post All".
+
+The base platform avoids this ambiguity by routing batch list actions through Reports: the Report request page shows the derived filter and lets the user correct it before running. A direct codeunit call has no such safety net and must resolve the scope explicitly.
+
+## Best Practice
+
+After calling `SetSelectionFilter`, test `MarkedOnly`. When it is false β meaning the user made no explicit selection, or selected all rows with Ctrl+A β discard the single-row primary key filter by copying the page source record (`Copy(Rec)`), which carries the full page view including all active filter groups. When `MarkedOnly` is true the user made a deliberate selection and that filter should be respected as-is. Refer to `set-selection-filter-list-scope.good.al` for the pattern.
+
+## Anti Pattern
+
+Passing the result of `SetSelectionFilter` directly to a processing codeunit without checking `MarkedOnly`. When the user runs the action with the cursor on row three and no rows highlighted, the codeunit receives a filter that matches only row three. The action appears to succeed but processes a fraction of the intended scope. The defect is hard to notice because no error is raised and the single-row run completes without complaint. See `set-selection-filter-list-scope.bad.al`.
+
+## See also
+
+`Page.SetSelectionFilter` β https://learn.microsoft.com/dynamics365/business-central/dev-itpro/developer/methods-auto/page/page-setselectionfilter-method
diff --git a/microsoft/knowledge/upgrade/check-only-triggers-do-not-migrate-data.bad.al b/microsoft/knowledge/upgrade/check-only-triggers-do-not-migrate-data.bad.al
new file mode 100644
index 0000000..af1dabf
--- /dev/null
+++ b/microsoft/knowledge/upgrade/check-only-triggers-do-not-migrate-data.bad.al
@@ -0,0 +1,32 @@
+codeunit 50303 "Upgrade Phases Bad"
+{
+ Subtype = Upgrade;
+
+ trigger OnCheckPreconditionsPerCompany()
+ begin
+ // A precondition check must not repair the data it is checking.
+ RenamePostingGroup();
+ end;
+
+ trigger OnValidateUpgradePerCompany()
+ begin
+ // Validation must not perform a migration omitted from OnUpgrade.
+ MigrateCustomerPostingGroups();
+ end;
+
+ local procedure RenamePostingGroup()
+ var
+ CustomerPostingGroup: Record "Customer Posting Group";
+ begin
+ if CustomerPostingGroup.Get('OLD') then
+ CustomerPostingGroup.Rename('NEW');
+ end;
+
+ local procedure MigrateCustomerPostingGroups()
+ var
+ Customer: Record Customer;
+ begin
+ Customer.SetRange("Customer Posting Group", 'OLD');
+ Customer.ModifyAll("Customer Posting Group", 'NEW');
+ end;
+}
diff --git a/microsoft/knowledge/upgrade/check-only-triggers-do-not-migrate-data.good.al b/microsoft/knowledge/upgrade/check-only-triggers-do-not-migrate-data.good.al
new file mode 100644
index 0000000..6929741
--- /dev/null
+++ b/microsoft/knowledge/upgrade/check-only-triggers-do-not-migrate-data.good.al
@@ -0,0 +1,63 @@
+codeunit 50302 "Upgrade Phases Good"
+{
+ Subtype = Upgrade;
+
+ trigger OnCheckPreconditionsPerCompany()
+ begin
+ CheckTargetPostingGroup();
+ end;
+
+ trigger OnUpgradePerCompany()
+ var
+ UpgradeTag: Codeunit "Upgrade Tag";
+ begin
+ if UpgradeTag.HasUpgradeTag(CustomerPostingGroupTag()) then
+ exit;
+
+ MigrateCustomerPostingGroups();
+ UpgradeTag.SetUpgradeTag(CustomerPostingGroupTag());
+ end;
+
+ trigger OnValidateUpgradePerCompany()
+ begin
+ CheckLegacyPostingGroupsRemoved();
+ end;
+
+ local procedure CheckTargetPostingGroup()
+ var
+ CustomerPostingGroup: Record "Customer Posting Group";
+ begin
+ if not CustomerPostingGroup.Get('NEW') then
+ Error(TargetGroupMissingErr);
+ end;
+
+ local procedure MigrateCustomerPostingGroups()
+ var
+ Customer: Record Customer;
+ begin
+ Customer.SetRange("Customer Posting Group", 'OLD');
+ if Customer.FindSet(true) then
+ repeat
+ Customer.Validate("Customer Posting Group", 'NEW');
+ Customer.Modify(true);
+ until Customer.Next() = 0;
+ end;
+
+ local procedure CheckLegacyPostingGroupsRemoved()
+ var
+ Customer: Record Customer;
+ begin
+ Customer.SetRange("Customer Posting Group", 'OLD');
+ if not Customer.IsEmpty() then
+ Error(MigrationIncompleteErr);
+ end;
+
+ local procedure CustomerPostingGroupTag(): Code[250]
+ begin
+ exit('MS-50302-CustomerPostingGroup-20260714');
+ end;
+
+ var
+ MigrationIncompleteErr: Label 'The legacy customer posting group was not migrated.';
+ TargetGroupMissingErr: Label 'Customer posting group NEW must exist before the upgrade.';
+}
diff --git a/microsoft/knowledge/upgrade/check-only-triggers-do-not-migrate-data.md b/microsoft/knowledge/upgrade/check-only-triggers-do-not-migrate-data.md
new file mode 100644
index 0000000..30f6ca7
--- /dev/null
+++ b/microsoft/knowledge/upgrade/check-only-triggers-do-not-migrate-data.md
@@ -0,0 +1,26 @@
+---
+bc-version: [all]
+domain: upgrade
+keywords: [on-check-preconditions, on-validate-upgrade, on-upgrade, read-only-check, data-migration]
+technologies: [al]
+countries: [w1]
+application-area: [all]
+---
+
+# Upgrade check triggers do not migrate data
+
+## Description
+
+`OnCheckPreconditionsPerCompany`/`PerDatabase` run before the upgrade to verify that it can start. `OnValidateUpgradePerCompany`/`PerDatabase` run after upgrade logic to verify that it succeeded. Treat both phases as read-only checks. The `OnUpgradePerCompany`/`PerDatabase` phase is where the platform expects actual data transformation.
+
+## Best Practice
+
+Have check triggers call query-only helpers that raise an error when an invariant fails. Put every `Insert`, `Modify`, `Delete`, `Rename`, `DataTransfer`, and other migration write behind helpers called from the matching `OnUpgrade...` trigger.
+
+See sample: `check-only-triggers-do-not-migrate-data.good.al`.
+
+## Anti Pattern
+
+Repairing data in `OnCheckPreconditions...` or finishing migration in `OnValidateUpgrade...`. Those writes blur the phase contract and make a check alter the state it is supposed to assess.
+
+See sample: `check-only-triggers-do-not-migrate-data.bad.al`.
diff --git a/microsoft/knowledge/upgrade/datatransfer-for-bulk-init.md b/microsoft/knowledge/upgrade/datatransfer-for-bulk-init.md
index 3eeaa46..988dfa4 100644
--- a/microsoft/knowledge/upgrade/datatransfer-for-bulk-init.md
+++ b/microsoft/knowledge/upgrade/datatransfer-for-bulk-init.md
@@ -1,5 +1,5 @@
---
-bc-version: [all]
+bc-version: [21..]
domain: upgrade
keywords: [datatransfer, large-dataset, bulk-update, modifyall, copyfields, new-field]
technologies: [al]
diff --git a/microsoft/knowledge/upgrade/datatransfer-skips-triggers-and-subscribers.bad.al b/microsoft/knowledge/upgrade/datatransfer-skips-triggers-and-subscribers.bad.al
index 800c828..31e8371 100644
--- a/microsoft/knowledge/upgrade/datatransfer-skips-triggers-and-subscribers.bad.al
+++ b/microsoft/knowledge/upgrade/datatransfer-skips-triggers-and-subscribers.bad.al
@@ -7,8 +7,8 @@ codeunit 50221 "Upgrade Existing Field"
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.
+ // DataTransfer skips the field's OnValidate logic and validation events,
+ // plus the table OnModify trigger and row-based modification events.
DT.SetTables(Database::Customer, Database::Customer);
DT.AddConstantValue(50000, Customer.FieldNo("Credit Limit (LCY)"));
DT.CopyFields();
diff --git a/microsoft/knowledge/upgrade/datatransfer-skips-triggers-and-subscribers.good.al b/microsoft/knowledge/upgrade/datatransfer-skips-triggers-and-subscribers.good.al
index 0475079..7dbf24c 100644
--- a/microsoft/knowledge/upgrade/datatransfer-skips-triggers-and-subscribers.good.al
+++ b/microsoft/knowledge/upgrade/datatransfer-skips-triggers-and-subscribers.good.al
@@ -1,16 +1,17 @@
-codeunit 50220 "Upgrade New Field Init"
+codeunit 50220 "Upgrade Trigger Aware"
{
Subtype = Upgrade;
- local procedure InitializeNewFlagOnMyTable()
+ local procedure UpdateCustomerCreditLimit()
var
- MyTable: Record "My Table";
- DT: DataTransfer;
+ Customer: Record Customer;
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();
+ if Customer.FindSet(true) then
+ repeat
+ // Validate runs the field OnValidate logic; Modify(true) separately
+ // runs the table OnModify trigger and its row-based events.
+ Customer.Validate("Credit Limit (LCY)", 50000);
+ Customer.Modify(true);
+ until Customer.Next() = 0;
end;
}
diff --git a/microsoft/knowledge/upgrade/datatransfer-skips-triggers-and-subscribers.md b/microsoft/knowledge/upgrade/datatransfer-skips-triggers-and-subscribers.md
index 785684f..34a406b 100644
--- a/microsoft/knowledge/upgrade/datatransfer-skips-triggers-and-subscribers.md
+++ b/microsoft/knowledge/upgrade/datatransfer-skips-triggers-and-subscribers.md
@@ -1,5 +1,5 @@
---
-bc-version: [all]
+bc-version: [21..]
domain: upgrade
keywords: [datatransfer, validate-trigger, event-subscriber, side-effects, business-logic]
technologies: [al]
@@ -11,18 +11,18 @@ application-area: [all]
## 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.
+`DataTransfer` writes sets directly at the database layer, so row-based triggers and events do not run. For `CopyFields`, that includes the table `OnModify` trigger and `OnBeforeModifyEvent`/`OnAfterModifyEvent`; direct field assignment also does not call field `OnValidate` or its validation events. These are separate behaviors: `Record.Validate(Field, Value)` runs field validation, while `Record.Modify(true)` runs the table `OnModify` trigger. Calling `Modify(true)` does not retroactively validate assigned fields.
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.
+Use `DataTransfer` when set-based transfer is safe and row-level business logic is intentionally unnecessary β initial population of a new field is the canonical case. When an existing field's validation must run, loop through records and call `Validate(Field, Value)`; if the table's modify trigger must also run, follow with `Modify(true)`. If performance requires `DataTransfer`, document exactly which field-validation and row-modification triggers or subscribers are intentionally bypassed and verify that derived data remains correct.
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.
+Reaching for `DataTransfer` to update an existing field with non-trivial `OnValidate` or `OnModify` logic, without confirming that both validation and row-modification subscribers can be skipped. Replacing it with only `Modify(true)` is also incomplete when field validation is required; call `Validate` for that field first.
See sample: `datatransfer-skips-triggers-and-subscribers.bad.al`.
diff --git a/microsoft/knowledge/upgrade/first-install-dataversion-zero-check.bad.al b/microsoft/knowledge/upgrade/first-install-dataversion-zero-check.bad.al
index e3381ad..da501e6 100644
--- a/microsoft/knowledge/upgrade/first-install-dataversion-zero-check.bad.al
+++ b/microsoft/knowledge/upgrade/first-install-dataversion-zero-check.bad.al
@@ -4,8 +4,7 @@ codeunit 50211 "Install My Extension"
trigger OnInstallAppPerCompany()
begin
- // No DataVersion() guard β this runs on every reinstall and upgrade
- // path, duplicating seed rows.
+ // No DataVersion() guard: a reinstall duplicates seed rows.
SeedDefaultRows();
end;
diff --git a/microsoft/knowledge/upgrade/first-install-dataversion-zero-check.md b/microsoft/knowledge/upgrade/first-install-dataversion-zero-check.md
index 260b15b..6324836 100644
--- a/microsoft/knowledge/upgrade/first-install-dataversion-zero-check.md
+++ b/microsoft/knowledge/upgrade/first-install-dataversion-zero-check.md
@@ -11,20 +11,21 @@ application-area: [all]
## 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.
+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')`. During reinstall, `DataVersion()` identifies the previously installed data version. The `OnInstallAppPerCompany` trigger uses this distinction to separate a brand-new install from a reinstall. Ordinary version upgrades do not run install code.
## 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.
+In `OnInstallAppPerCompany`, fetch the current `ModuleInfo` via `NavApp.GetCurrentModuleInfo`, compare `AppInfo.DataVersion()` to `Version.Create('0.0.0.0')`, and run first-install seed logic only when they match. On a non-zero data version, follow the reinstall path or exit.
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.
+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, first-install seed code can run again and duplicate 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`.
+- `install-code-does-not-run-on-version-upgrade.md` β ordinary version upgrades invoke upgrade code, not install code.
diff --git a/microsoft/knowledge/upgrade/install-code-does-not-run-on-version-upgrade.bad.al b/microsoft/knowledge/upgrade/install-code-does-not-run-on-version-upgrade.bad.al
new file mode 100644
index 0000000..bb19d21
--- /dev/null
+++ b/microsoft/knowledge/upgrade/install-code-does-not-run-on-version-upgrade.bad.al
@@ -0,0 +1,20 @@
+codeunit 50306 "My App Install Only"
+{
+ Subtype = Install;
+
+ trigger OnInstallAppPerCompany()
+ begin
+ // A normal version upgrade never invokes this migration.
+ MigrateLegacySetup();
+ end;
+
+ local procedure MigrateLegacySetup()
+ var
+ MyAppSetup: Record "My App Setup";
+ begin
+ if MyAppSetup.Get() then begin
+ MyAppSetup."Configuration Version" := 2;
+ MyAppSetup.Modify(true);
+ end;
+ end;
+}
diff --git a/microsoft/knowledge/upgrade/install-code-does-not-run-on-version-upgrade.good.al b/microsoft/knowledge/upgrade/install-code-does-not-run-on-version-upgrade.good.al
new file mode 100644
index 0000000..c77f3cf
--- /dev/null
+++ b/microsoft/knowledge/upgrade/install-code-does-not-run-on-version-upgrade.good.al
@@ -0,0 +1,48 @@
+codeunit 50304 "My App Install"
+{
+ Subtype = Install;
+
+ trigger OnInstallAppPerCompany()
+ begin
+ InitializeSetup();
+ end;
+
+ local procedure InitializeSetup()
+ var
+ MyAppSetup: Record "My App Setup";
+ begin
+ if MyAppSetup.IsEmpty() then
+ MyAppSetup.Insert(true);
+ end;
+}
+
+codeunit 50305 "My App Upgrade"
+{
+ Subtype = Upgrade;
+
+ trigger OnUpgradePerCompany()
+ var
+ UpgradeTag: Codeunit "Upgrade Tag";
+ begin
+ if UpgradeTag.HasUpgradeTag(ConfigurationVersionTag()) then
+ exit;
+
+ MigrateLegacySetup();
+ UpgradeTag.SetUpgradeTag(ConfigurationVersionTag());
+ end;
+
+ local procedure MigrateLegacySetup()
+ var
+ MyAppSetup: Record "My App Setup";
+ begin
+ if MyAppSetup.Get() then begin
+ MyAppSetup."Configuration Version" := 2;
+ MyAppSetup.Modify(true);
+ end;
+ end;
+
+ local procedure ConfigurationVersionTag(): Code[250]
+ begin
+ exit('MS-50305-ConfigurationVersion-20260714');
+ end;
+}
diff --git a/microsoft/knowledge/upgrade/install-code-does-not-run-on-version-upgrade.md b/microsoft/knowledge/upgrade/install-code-does-not-run-on-version-upgrade.md
new file mode 100644
index 0000000..12f432f
--- /dev/null
+++ b/microsoft/knowledge/upgrade/install-code-does-not-run-on-version-upgrade.md
@@ -0,0 +1,26 @@
+---
+bc-version: [all]
+domain: upgrade
+keywords: [install-codeunit, subtype-install, on-install-app, version-upgrade, upgrade-codeunit]
+technologies: [al]
+countries: [w1]
+application-area: [all]
+---
+
+# Install code does not run during a version upgrade
+
+## Description
+
+An install codeunit runs when an extension is installed for the first time or an uninstalled version is installed again. Installing a higher extension version through the data-upgrade operation does not invoke `OnInstallAppPerCompany` or `OnInstallAppPerDatabase`. Ordinary version-to-version migration is dispatched only through upgrade codeunits.
+
+## Best Practice
+
+Use `Subtype = Install` for first-install and reinstall initialization. Put version migration in a separate `Subtype = Upgrade` codeunit and enter it from `OnUpgradePerCompany` or `OnUpgradePerDatabase`.
+
+See sample: `install-code-does-not-run-on-version-upgrade.good.al`.
+
+## Anti Pattern
+
+Putting a schema or data migration only in an install trigger and expecting it to run when a higher app version is upgraded. The migration is never invoked on that path.
+
+See sample: `install-code-does-not-run-on-version-upgrade.bad.al`.
diff --git a/microsoft/knowledge/upgrade/minimize-onvalidate-upgrade-triggers.bad.al b/microsoft/knowledge/upgrade/minimize-onvalidate-upgrade-triggers.bad.al
index 69994e6..35b848b 100644
--- a/microsoft/knowledge/upgrade/minimize-onvalidate-upgrade-triggers.bad.al
+++ b/microsoft/knowledge/upgrade/minimize-onvalidate-upgrade-triggers.bad.al
@@ -4,10 +4,21 @@ codeunit 50235 "Upgrade With Validation"
trigger OnValidateUpgradePerCompany()
begin
- // No skip logic and no written justification β full-table validation
- // runs on every single upgrade pass.
+ // A full-table scan repeats on every upgrade.
ValidateAllCustomers();
end;
- local procedure ValidateAllCustomers() begin end;
+ local procedure ValidateAllCustomers()
+ var
+ Customer: Record Customer;
+ begin
+ if Customer.FindSet() then
+ repeat
+ if Customer."Customer Posting Group" = 'OLD' then
+ Error(MigrationIncompleteErr);
+ until Customer.Next() = 0;
+ end;
+
+ var
+ MigrationIncompleteErr: Label 'The legacy customer posting group was not migrated.';
}
diff --git a/microsoft/knowledge/upgrade/minimize-onvalidate-upgrade-triggers.good.al b/microsoft/knowledge/upgrade/minimize-onvalidate-upgrade-triggers.good.al
index 9a5a83b..8680775 100644
--- a/microsoft/knowledge/upgrade/minimize-onvalidate-upgrade-triggers.good.al
+++ b/microsoft/knowledge/upgrade/minimize-onvalidate-upgrade-triggers.good.al
@@ -3,23 +3,19 @@ codeunit 50234 "Upgrade With Validation"
Subtype = Upgrade;
trigger OnValidateUpgradePerCompany()
+ begin
+ CheckNoLegacyPostingGroups();
+ end;
+
+ local procedure CheckNoLegacyPostingGroups()
var
- UpgradeTag: Codeunit "Upgrade Tag";
+ Customer: Record Customer;
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());
+ Customer.SetRange("Customer Posting Group", 'OLD');
+ if not Customer.IsEmpty() then
+ Error(MigrationIncompleteErr);
end;
- local procedure ValidateAllCustomers() begin end;
-
- local procedure MyValidationUpgradeTag(): Code[250]
- begin
- exit('MS-123456-CustomerValidation-20240101');
- end;
+ var
+ MigrationIncompleteErr: Label 'The legacy customer posting group was not migrated.';
}
diff --git a/microsoft/knowledge/upgrade/minimize-onvalidate-upgrade-triggers.md b/microsoft/knowledge/upgrade/minimize-onvalidate-upgrade-triggers.md
index 2c02def..b9e5e13 100644
--- a/microsoft/knowledge/upgrade/minimize-onvalidate-upgrade-triggers.md
+++ b/microsoft/knowledge/upgrade/minimize-onvalidate-upgrade-triggers.md
@@ -1,26 +1,26 @@
---
bc-version: [all]
domain: upgrade
-keywords: [on-validate-upgrade-per-company, performance-impact, skip-logic, justification, upgrade-tag]
+keywords: [on-validate-upgrade-per-company, performance-impact, bounded-query, justification, read-only-check]
technologies: [al]
countries: [w1]
application-area: [all]
---
-# Performance-impacting upgrade triggers need justification and skip logic
+# Keep upgrade validation checks bounded
## 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.
+Triggers such as `OnValidateUpgradePerCompany` run on every upgrade pass. A full-table scan or cross-table validation therefore adds cost to every upgrade of every tenant. Validation is a read-only lifecycle check, so it cannot make itself one-time by writing an upgrade tag.
## 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.
+Filter directly to invalid rows and use `IsEmpty` or another bounded existence check where possible. If a broad validation is unavoidable, document the invariant that requires it and keep all data changes in `OnUpgrade...`.
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.
+Reading every record in `OnValidateUpgradePerCompany` when a filtered existence check can prove the same invariant. The scan repeats on every upgrade.
See sample: `minimize-onvalidate-upgrade-triggers.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
index 22290a4..578db0b 100644
--- a/microsoft/knowledge/upgrade/obsoletion-requires-reason-and-tag.bad.al
+++ b/microsoft/knowledge/upgrade/obsoletion-requires-reason-and-tag.bad.al
@@ -1,7 +1,7 @@
codeunit 50228 "Old Method Holder"
{
- // ObsoleteState set without ObsoleteReason or ObsoleteTag.
- [Obsolete('')]
+ // Methods use the attribute, but empty reason and tag give no migration path.
+ [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
index 8562b0c..0a0602a 100644
--- a/microsoft/knowledge/upgrade/obsoletion-requires-reason-and-tag.good.al
+++ b/microsoft/knowledge/upgrade/obsoletion-requires-reason-and-tag.good.al
@@ -3,7 +3,7 @@ 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.
+ // The method remains callable during its deprecation window.
end;
procedure NewMethod()
diff --git a/microsoft/knowledge/upgrade/obsoletion-requires-reason-and-tag.md b/microsoft/knowledge/upgrade/obsoletion-requires-reason-and-tag.md
index 0f2e11e..d6ec37a 100644
--- a/microsoft/knowledge/upgrade/obsoletion-requires-reason-and-tag.md
+++ b/microsoft/knowledge/upgrade/obsoletion-requires-reason-and-tag.md
@@ -7,27 +7,26 @@ countries: [w1]
application-area: [all]
---
-# Mark obsolete elements with `ObsoleteState`, `ObsoleteReason`, and `ObsoleteTag`
+# Give every obsolete element a reason and tag
## Description
-When a procedure, field, table, page, or enum value is being retired, AL requires three pieces of metadata to declare the deprecation:
+AL has two obsoletion mechanisms, depending on the symbol:
-- `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'`).
+- Objects, fields, enum types, and enum values use the `ObsoleteState`, `ObsoleteReason`, and `ObsoleteTag` properties. `Pending` warns while the element remains available; `Removed` blocks references.
+- Methods, variables, events, and other symbols use `[Obsolete('reason', 'tag')]`. They do not have an `ObsoleteState` property.
-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.
+In both forms, the reason should name the replacement and the tag should identify when the element became obsolete. Empty or missing guidance leaves consumers without an actionable migration path.
## 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.
+For an object or field, set all three properties together. For a method, variable, or event, provide both `[Obsolete]` arguments. Keep the original tag stable through the lifecycle rather than changing it to a planned removal version.
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.
+Setting only `ObsoleteState = Pending`/`Removed` on an object or field, or using `[Obsolete('', '')]` on a method, variable, or event. Both forms produce deprecation metadata without useful replacement guidance or traceability.
See sample: `obsoletion-requires-reason-and-tag.bad.al`.
diff --git a/microsoft/knowledge/upgrade/register-upgrade-tags-with-subscribers.bad.al b/microsoft/knowledge/upgrade/register-upgrade-tags-with-subscribers.bad.al
index adf5cf5..3a50448 100644
--- a/microsoft/knowledge/upgrade/register-upgrade-tags-with-subscribers.bad.al
+++ b/microsoft/knowledge/upgrade/register-upgrade-tags-with-subscribers.bad.al
@@ -16,5 +16,6 @@ codeunit 50213 "Upgrade Tag Registration"
exit('MS-123456-MyFeature-20240101');
end;
- // No OnGetPerCompanyUpgradeTags subscriber β the tag is unknown to the platform.
+ // No OnGetPerCompanyUpgradeTags subscriber: SetAllUpgradeTags cannot seed this
+ // historical step for a newly initialized company, so it can run unnecessarily.
}
diff --git a/microsoft/knowledge/upgrade/register-upgrade-tags-with-subscribers.good.al b/microsoft/knowledge/upgrade/register-upgrade-tags-with-subscribers.good.al
index 02362c9..a214717 100644
--- a/microsoft/knowledge/upgrade/register-upgrade-tags-with-subscribers.good.al
+++ b/microsoft/knowledge/upgrade/register-upgrade-tags-with-subscribers.good.al
@@ -1,18 +1,6 @@
-codeunit 50212 "Upgrade Tag Registration"
+codeunit 50212 "Upgrade Tag Definitions"
{
- 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]
+ procedure MyUpgradeTag(): Code[250]
begin
exit('MS-123456-MyFeature-20240101');
end;
@@ -23,3 +11,34 @@ codeunit 50212 "Upgrade Tag Registration"
PerCompanyUpgradeTags.Add(MyUpgradeTag());
end;
}
+
+codeunit 50214 "Upgrade Tagged Feature"
+{
+ Subtype = Upgrade;
+
+ trigger OnUpgradePerCompany()
+ var
+ UpgradeTag: Codeunit "Upgrade Tag";
+ Tags: Codeunit "Upgrade Tag Definitions";
+ begin
+ if UpgradeTag.HasUpgradeTag(Tags.MyUpgradeTag()) then
+ exit;
+ // Upgrade work ...
+ UpgradeTag.SetUpgradeTag(Tags.MyUpgradeTag());
+ end;
+}
+
+codeunit 50215 "Install Tagged Feature"
+{
+ Subtype = Install;
+
+ trigger OnInstallAppPerCompany()
+ var
+ UpgradeTag: Codeunit "Upgrade Tag";
+ Tags: Codeunit "Upgrade Tag Definitions";
+ begin
+ // Existing-company install path; new-company initialization uses
+ // SetAllUpgradeTags and the subscriber above.
+ UpgradeTag.SetUpgradeTag(Tags.MyUpgradeTag());
+ end;
+}
diff --git a/microsoft/knowledge/upgrade/register-upgrade-tags-with-subscribers.md b/microsoft/knowledge/upgrade/register-upgrade-tags-with-subscribers.md
index a413520..e5e1983 100644
--- a/microsoft/knowledge/upgrade/register-upgrade-tags-with-subscribers.md
+++ b/microsoft/knowledge/upgrade/register-upgrade-tags-with-subscribers.md
@@ -7,22 +7,22 @@ countries: [w1]
application-area: [all]
---
-# Register every upgrade tag with the platform via an event subscriber
+# Register upgrade tags that must be seeded for new companies
## 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.
+`SetUpgradeTag(Tag)` directly records a completed per-company upgrade step; `HasUpgradeTag(Tag)` can then guard that step on later upgrades. The `OnGetPerCompanyUpgradeTags` subscriber serves a different path: it contributes tags to the list used by `SetAllUpgradeTags()` when a new company is initialized, marking historical upgrade steps complete so they do not run against a company that starts on the current schema.
-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.
+Registration is not install-time seeding. When an extension is installed into an existing company and a tag must start as complete, the install code must call `SetUpgradeTag` explicitly. For new-company initialization, codeunit `Company Initialize` calls `SetAllUpgradeTags`, which obtains subscriber-provided per-company tags and inserts missing ones. Database-scoped upgrade steps use `HasDatabaseUpgradeTag`/`SetDatabaseUpgradeTag` and the corresponding per-database list.
## 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.
+In the upgrade codeunit, guard work with `HasUpgradeTag` and call `SetUpgradeTag` only after successful completion. Seed the same tag explicitly from `OnInstallAppPerCompany` when first-install logic should not run as a later upgrade. Also add historical per-company tags to `OnGetPerCompanyUpgradeTags` so `SetAllUpgradeTags` marks them complete for newly created companies. Keep the tag definition shared so all paths use the exact same value.
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.
+Assuming an `OnGetPerCompanyUpgradeTags` subscriber sets tags during extension installation, or omitting the subscriber and allowing old upgrade steps to run when `SetAllUpgradeTags` initializes a new company. The subscriber supplies a list; only `SetAllUpgradeTags` or an explicit `SetUpgradeTag` call persists it.
See sample: `register-upgrade-tags-with-subscribers.bad.al`.
diff --git a/microsoft/knowledge/upgrade/upgrade-codeunit-subtype.bad.al b/microsoft/knowledge/upgrade/upgrade-codeunit-subtype.bad.al
index 024443c..27c972c 100644
--- a/microsoft/knowledge/upgrade/upgrade-codeunit-subtype.bad.al
+++ b/microsoft/knowledge/upgrade/upgrade-codeunit-subtype.bad.al
@@ -1,7 +1,7 @@
codeunit 50201 "Upgrade My Feature"
{
- // Missing Subtype = Upgrade; the OnUpgrade trigger is never dispatched.
- trigger OnUpgradePerCompany()
+ // This compiles, but no Subtype = Upgrade trigger wires it to the pipeline.
+ procedure RunUpgrade()
begin
UpgradeMyFeature();
end;
diff --git a/microsoft/knowledge/upgrade/upgrade-codeunit-subtype.md b/microsoft/knowledge/upgrade/upgrade-codeunit-subtype.md
index 2dba21a..a9fee7c 100644
--- a/microsoft/knowledge/upgrade/upgrade-codeunit-subtype.md
+++ b/microsoft/knowledge/upgrade/upgrade-codeunit-subtype.md
@@ -11,7 +11,7 @@ application-area: [all]
## 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.
+A codeunit only participates in the upgrade pipeline when it sets `Subtype = Upgrade`. The platform then permits and dispatches the `OnUpgradePerCompany` and `OnUpgradePerDatabase` triggers on that codeunit during upgrade. A normal codeunit can contain an upgrade-like `RunUpgrade` procedure, but the platform does not discover or invoke it automatically. Conversely, any procedure invoked transitively from an `OnUpgrade...` trigger of an upgrade codeunit is upgrade code regardless of where the helper lives, and the upgrade rules apply to it.
## Best Practice
diff --git a/microsoft/knowledge/web-services/link-api-parts-on-systemid-and-set-multiplicity.bad.al b/microsoft/knowledge/web-services/link-api-parts-on-systemid-and-set-multiplicity.bad.al
new file mode 100644
index 0000000..a629672
--- /dev/null
+++ b/microsoft/knowledge/web-services/link-api-parts-on-systemid-and-set-multiplicity.bad.al
@@ -0,0 +1,80 @@
+page 50353 "WS Order API Bad"
+{
+ PageType = API;
+ APIPublisher = 'contoso';
+ APIGroup = 'sales';
+ APIVersion = 'v1.0';
+ EntityName = 'order';
+ EntitySetName = 'orders';
+ ODataKeyFields = SystemId;
+ SourceTable = "Sales Header";
+
+ layout
+ {
+ area(content)
+ {
+ repeater(records)
+ {
+ part(lines; "WS Order Line API Bad")
+ {
+ EntityName = 'orderLine';
+ EntitySetName = 'orderLines';
+ Multiplicity = ZeroOrOne;
+ SubPageLink = "Order No." = Field("No.");
+ }
+ }
+ }
+ }
+}
+
+table 50353 "WS Order Line Bad"
+{
+ fields
+ {
+ field(1; "Entry No."; Integer)
+ {
+ AutoIncrement = true;
+ }
+ field(2; "Order No."; Code[20])
+ {
+ TableRelation = "Sales Header"."No.";
+ }
+ }
+
+ keys
+ {
+ key(PK; "Entry No.")
+ {
+ Clustered = true;
+ }
+ }
+}
+
+page 50354 "WS Order Line API Bad"
+{
+ PageType = API;
+ APIPublisher = 'contoso';
+ APIGroup = 'sales';
+ APIVersion = 'v1.0';
+ EntityName = 'orderLine';
+ EntitySetName = 'orderLines';
+ ODataKeyFields = SystemId;
+ SourceTable = "WS Order Line Bad";
+
+ layout
+ {
+ area(content)
+ {
+ repeater(records)
+ {
+ field(id; Rec.SystemId)
+ {
+ Editable = false;
+ }
+ field(orderNumber; Rec."Order No.")
+ {
+ }
+ }
+ }
+ }
+}
diff --git a/microsoft/knowledge/web-services/link-api-parts-on-systemid-and-set-multiplicity.good.al b/microsoft/knowledge/web-services/link-api-parts-on-systemid-and-set-multiplicity.good.al
new file mode 100644
index 0000000..4b7482a
--- /dev/null
+++ b/microsoft/knowledge/web-services/link-api-parts-on-systemid-and-set-multiplicity.good.al
@@ -0,0 +1,151 @@
+page 50350 "WS Order API"
+{
+ PageType = API;
+ APIPublisher = 'contoso';
+ APIGroup = 'sales';
+ APIVersion = 'v1.0';
+ EntityName = 'order';
+ EntitySetName = 'orders';
+ ODataKeyFields = SystemId;
+ SourceTable = "Sales Header";
+
+ layout
+ {
+ area(content)
+ {
+ repeater(records)
+ {
+ field(id; Rec.SystemId)
+ {
+ Editable = false;
+ }
+ part(lines; "WS Order Line API")
+ {
+ EntityName = 'orderLine';
+ EntitySetName = 'orderLines';
+ SubPageLink = "Order Id" = Field(SystemId);
+ }
+ part(summary; "WS Order Summary API")
+ {
+ EntityName = 'orderSummary';
+ Multiplicity = ZeroOrOne;
+ SubPageLink = "Order Id" = Field(SystemId);
+ }
+ }
+ }
+ }
+}
+
+table 50350 "WS Order Line"
+{
+ fields
+ {
+ field(1; "Entry No."; Integer)
+ {
+ AutoIncrement = true;
+ }
+ field(2; "Order Id"; Guid)
+ {
+ TableRelation = "Sales Header".SystemId;
+ }
+ field(3; Description; Text[100])
+ {
+ }
+ }
+
+ keys
+ {
+ key(PK; "Entry No.")
+ {
+ Clustered = true;
+ }
+ }
+}
+
+table 50351 "WS Order Summary"
+{
+ fields
+ {
+ field(1; "Order Id"; Guid)
+ {
+ TableRelation = "Sales Header".SystemId;
+ }
+ field(2; Summary; Text[100])
+ {
+ }
+ }
+
+ keys
+ {
+ key(PK; "Order Id")
+ {
+ Clustered = true;
+ }
+ }
+}
+
+page 50351 "WS Order Line API"
+{
+ PageType = API;
+ APIPublisher = 'contoso';
+ APIGroup = 'sales';
+ APIVersion = 'v1.0';
+ EntityName = 'orderLine';
+ EntitySetName = 'orderLines';
+ ODataKeyFields = SystemId;
+ SourceTable = "WS Order Line";
+ DelayedInsert = true;
+
+ layout
+ {
+ area(content)
+ {
+ repeater(records)
+ {
+ field(id; Rec.SystemId)
+ {
+ Editable = false;
+ }
+ field(orderId; Rec."Order Id")
+ {
+ }
+ field(description; Rec.Description)
+ {
+ }
+ }
+ }
+ }
+}
+
+page 50352 "WS Order Summary API"
+{
+ PageType = API;
+ APIPublisher = 'contoso';
+ APIGroup = 'sales';
+ APIVersion = 'v1.0';
+ EntityName = 'orderSummary';
+ EntitySetName = 'orderSummaries';
+ ODataKeyFields = SystemId;
+ SourceTable = "WS Order Summary";
+ DelayedInsert = true;
+
+ layout
+ {
+ area(content)
+ {
+ repeater(records)
+ {
+ field(id; Rec.SystemId)
+ {
+ Editable = false;
+ }
+ field(orderId; Rec."Order Id")
+ {
+ }
+ field(summary; Rec.Summary)
+ {
+ }
+ }
+ }
+ }
+}
diff --git a/microsoft/knowledge/web-services/link-api-parts-on-systemid-and-set-multiplicity.md b/microsoft/knowledge/web-services/link-api-parts-on-systemid-and-set-multiplicity.md
new file mode 100644
index 0000000..4cf81d6
--- /dev/null
+++ b/microsoft/knowledge/web-services/link-api-parts-on-systemid-and-set-multiplicity.md
@@ -0,0 +1,30 @@
+---
+bc-version: [17..]
+domain: web-services
+keywords: [api-page, page-part, subpagelink, systemid, multiplicity, deep-insert, navigation-property]
+technologies: [al]
+countries: [w1]
+application-area: [all]
+---
+
+# Link API parts on SystemId and choose the correct multiplicity
+
+## Description
+
+`Multiplicity` is available from runtime 6.3 (Business Central 17.3) and defaults an API page part to a 1:N collection. The multiplicity-specific guidance therefore does not apply to BC 17.0 through 17.2. An API page part creates an OData navigation property and, for collection multiplicity, enables deep insert of child entities. When a custom parent API is keyed by its immutable `SystemId`, its child should carry a related GUID foreign key so the navigation constraint uses that same stable external identity. `Multiplicity` controls whether metadata exposes an object (`ZeroOrOne`) or a collection (`Many`).
+
+## Best Practice
+
+Define the child foreign key as `Guid` with a `TableRelation` to the parent table's `SystemId`, then use `SubPageLink = "" = Field(SystemId)` on the parent API page. A child collection may omit `Multiplicity` and rely on the default 1:N relationship, or declare `Multiplicity = Many` explicitly. Set `Multiplicity = ZeroOrOne` when the intended navigation metadata is a singleton.
+
+See sample: `link-api-parts-on-systemid-and-set-multiplicity.good.al`.
+
+## Anti Pattern
+
+On a parent API with `ODataKeyFields = SystemId`, linking a child business field such as `"Order No."` to the parent's `"No."` creates a second identity scheme for navigation instead of using the contract's stable GUID. A separate defect is an explicit `Multiplicity` that conflicts with the intended shape, such as `ZeroOrOne` on an order-lines collection or `Many` on a singleton. Do not treat omission alone as a defect: it is valid for a collection because the default is 1:N, while an intended singleton must explicitly use `Multiplicity = ZeroOrOne`.
+
+See sample: `link-api-parts-on-systemid-and-set-multiplicity.bad.al`.
+
+## Source
+
+[Developing a custom API](https://learn.microsoft.com/dynamics365/business-central/dev-itpro/developer/devenv-develop-custom-api) and [Multiplicity property](https://learn.microsoft.com/dynamics365/business-central/dev-itpro/developer/properties/devenv-multiplicity-property).
diff --git a/microsoft/knowledge/web-services/set-required-api-page-properties.bad.al b/microsoft/knowledge/web-services/set-required-api-page-properties.bad.al
index 927bc2d..c248cb2 100644
--- a/microsoft/knowledge/web-services/set-required-api-page-properties.bad.al
+++ b/microsoft/knowledge/web-services/set-required-api-page-properties.bad.al
@@ -1,12 +1,13 @@
-// Malformed API endpoint: APIPublisher and APIGroup are missing, and there is
-// no SourceTable. The page compiles but the route cannot be composed, so the
-// entity is never published where an integration expects it.
+// APIVersion is omitted. This is valid, but the endpoint defaults to beta
+// instead of publishing the intended explicit stable contract.
page 50341 "WS Required Props Bad"
{
PageType = API;
- APIVersion = 'v1.0';
+ APIPublisher = 'contoso';
+ APIGroup = 'sales';
EntityName = 'customer';
EntitySetName = 'customers';
+ SourceTable = Customer;
layout
{
diff --git a/microsoft/knowledge/web-services/set-required-api-page-properties.md b/microsoft/knowledge/web-services/set-required-api-page-properties.md
index 9bef346..496db1a 100644
--- a/microsoft/knowledge/web-services/set-required-api-page-properties.md
+++ b/microsoft/knowledge/web-services/set-required-api-page-properties.md
@@ -7,20 +7,20 @@ countries: [w1]
application-area: [all]
---
-# Declare every required property on a PageType = API page
+# Declare API routing properties and an explicit stable version
## Description
-An API page projects a table as an OData v4 / API v2 endpoint, but the platform only publishes that endpoint when the page carries the full set of identifying properties: `APIPublisher`, `APIGroup`, `APIVersion`, `EntityName`, `EntitySetName`, and a backing `SourceTable`. These properties are what compose the route β `/api////` β so omitting any one of them yields a page that compiles yet never surfaces as a usable endpoint, or surfaces at an unexpected address. An LLM that has mostly seen ordinary list/card pages tends to treat `PageType = API` as a cosmetic switch and forgets the identifying metadata, because a normal page needs none of it. This file is remedial precisely because the missing-property failure is silent: there is no runtime error, only an endpoint that clients cannot reach.
+An API page needs `APIPublisher`, `APIGroup`, `EntityName`, `EntitySetName`, and a backing `SourceTable` to define its routed entity. `APIVersion` is different: it is optional at the language level and defaults to `beta`. Omitting it therefore does not mean the page has no version; it publishes under the preview contract. A production integration that intends a stable route should set a `vX.Y` version explicitly rather than rely on that default.
## Best Practice
-On every `PageType = API` page set all six properties explicitly: `APIPublisher` (your publisher tag), `APIGroup` (the logical grouping for related entities), `APIVersion` (a `vX.Y` value such as `'v1.0'`), `EntityName` (singular), `EntitySetName` (plural), and `SourceTable` (the projected table). Expose the record's fields inside a single `field(...)` repeater under `area(content)`. Treat the six properties as a mandatory checklist that travels with the `PageType = API` declaration itself.
+Declare the five routing/entity properties required by the API page and set `APIVersion` explicitly for a stable published contract, for example `'v1.0'`. Expose the record's fields inside a repeater under `area(content)`. Review missing routing metadata as a malformed API definition, but review a missing `APIVersion` as unintended publication under `beta`, not as an unpublished endpoint.
See sample: `set-required-api-page-properties.good.al`.
## Anti Pattern
-Writing a page with `PageType = API` and a `SourceTable` but leaving out `APIPublisher` and `APIGroup` (and, worse, omitting `SourceTable` entirely). The page compiles, so it looks finished, but the endpoint is malformed: with no publisher and group the route cannot be composed, and the entity is never published where an integration expects it. The detection signal: a `PageType = API` page missing one or more of the six identifying properties.
+Leaving out `APIPublisher`, `APIGroup`, `EntityName`, `EntitySetName`, or `SourceTable` leaves the API definition incomplete. A subtler contract defect is declaring all of those but omitting `APIVersion`: the page is exposed as `beta`, which is valid runtime behavior but not the explicit stable route a production client expects.
See sample: `set-required-api-page-properties.bad.al`.
diff --git a/microsoft/knowledge/web-services/version-apis-by-adding-not-mutating-published-versions.good.al b/microsoft/knowledge/web-services/version-apis-by-adding-not-mutating-published-versions.good.al
index 97aeb3a..9f1246f 100644
--- a/microsoft/knowledge/web-services/version-apis-by-adding-not-mutating-published-versions.good.al
+++ b/microsoft/knowledge/web-services/version-apis-by-adding-not-mutating-published-versions.good.al
@@ -1,13 +1,11 @@
-// Additive versioning: v2.0 carries the new shape while v1.0 stays published and
-// unchanged. APIVersion accepts a list, so both contracts are served and
-// existing clients keep working while new clients adopt v2.0.
-page 50354 "WS API Versioning Good"
+// The original page remains the unchanged v1.0 contract.
+page 50354 "Customer API v1"
{
PageType = API;
Caption = 'customer';
APIPublisher = 'contoso';
APIGroup = 'sales';
- APIVersion = 'v2.0', 'v1.0';
+ APIVersion = 'v1.0';
EntityName = 'customer';
EntitySetName = 'customers';
ODataKeyFields = SystemId;
@@ -37,3 +35,41 @@ page 50354 "WS API Versioning Good"
}
}
}
+
+// A separate object carries the changed v2.0 shape.
+page 50356 "Customer API v2"
+{
+ PageType = API;
+ Caption = 'customer';
+ APIPublisher = 'contoso';
+ APIGroup = 'sales';
+ APIVersion = 'v2.0';
+ EntityName = 'customer';
+ EntitySetName = 'customers';
+ ODataKeyFields = SystemId;
+ SourceTable = Customer;
+ DelayedInsert = true;
+
+ layout
+ {
+ area(content)
+ {
+ repeater(records)
+ {
+ field(id; Rec.SystemId)
+ {
+ Caption = 'id';
+ Editable = false;
+ }
+ field(number; Rec."No.")
+ {
+ Caption = 'number';
+ }
+ field(legalName; Rec.Name)
+ {
+ Caption = 'legalName';
+ }
+ }
+ }
+ }
+}
diff --git a/microsoft/knowledge/web-services/version-apis-by-adding-not-mutating-published-versions.md b/microsoft/knowledge/web-services/version-apis-by-adding-not-mutating-published-versions.md
index af998ed..bfd2c9f 100644
--- a/microsoft/knowledge/web-services/version-apis-by-adding-not-mutating-published-versions.md
+++ b/microsoft/knowledge/web-services/version-apis-by-adding-not-mutating-published-versions.md
@@ -7,20 +7,20 @@ countries: [w1]
application-area: [all]
---
-# Version APIs by adding a new APIVersion, not by mutating a published one
+# Version changed API shapes with a new page object
## Description
-Once an API version is published, external clients depend on its exact shape β the entity name, the set of exposed fields, the key β as a frozen contract. Changing any of that on the already-published version is a breaking change delivered silently: integrations that worked yesterday fail today with no warning. The platform gives you a clean way to evolve without breaking anyone, because `APIVersion` accepts a *list* of versions on one page. The correct way to change a published API is to add the new version (`'v2.0'`) alongside the existing one (`'v1.0'`) β or publish a new API page for it β so both contracts are served side by side and clients migrate on their own schedule. LLMs tend to "fix" an API by editing the live version in place, because in ordinary code you just change what's wrong; this file is remedial because a published API version is an immutable contract in a way ordinary internal code is not.
+Once an API version is published, external clients depend on its exact shape β entity names, fields, keys, and behavior β as a stable contract. `APIVersion` can list several versions on one API page, but every listed route is generated from that same page object and therefore exposes the same shape. Adding `'v2.0'` to a page and then changing its fields changes what both `v1.0` and `v2.0` serve. To preserve the v1 shape while introducing a different v2 shape, keep the v1 page unchanged and create a separate page object for v2.
## Best Practice
-When a published API must change shape, keep the old version's contract intact and add the new one to the `APIVersion` list β `APIVersion = 'v2.0', 'v1.0';`. The page now serves both `v1.0` (unchanged) and `v2.0` (carrying the new shape), so existing clients keep working while new clients adopt `v2.0`. Retire the old version only after consumers have migrated.
+Keep the existing page object and its `APIVersion = 'v1.0'` contract unchanged. Copy the page to a new object ID, set that object's `APIVersion = 'v2.0'`, and make the v2-only shape changes there. A multi-value `APIVersion` list is appropriate only when the exact same page shape is supported under each listed version.
See sample: `version-apis-by-adding-not-mutating-published-versions.good.al`.
## Anti Pattern
-Editing the published `v1.0` page in place β renaming its `EntityName` or removing an exposed field β so the single declared version now serves a different contract than the one clients integrated against. Every consumer of the old shape breaks without notice. The detection signal: a change that renames the entity or removes a field on an existing published `APIVersion` instead of adding a new version to the list.
+Editing the published `v1.0` page in place breaks its clients. So does adding `v2.0` to that same page and assuming subsequent field changes apply only to v2: both routes use one object shape. The detection signal is a breaking shape change without a separate API page object retaining the old version.
See sample: `version-apis-by-adding-not-mutating-published-versions.bad.al`.
diff --git a/microsoft/knowledge/web-services/webhook-eligibility-and-validationtoken-renewal.bad.al b/microsoft/knowledge/web-services/webhook-eligibility-and-validationtoken-renewal.bad.al
new file mode 100644
index 0000000..9baaa71
--- /dev/null
+++ b/microsoft/knowledge/web-services/webhook-eligibility-and-validationtoken-renewal.bad.al
@@ -0,0 +1,22 @@
+query 50355 "WS Webhook Customer Query"
+{
+ QueryType = API;
+ APIPublisher = 'contoso';
+ APIGroup = 'sales';
+ APIVersion = 'v1.0';
+ EntityName = 'webhookCustomer';
+ EntitySetName = 'webhookCustomers';
+
+ elements
+ {
+ dataitem(customer; Customer)
+ {
+ column(id; SystemId)
+ {
+ }
+ column(displayName; Name)
+ {
+ }
+ }
+ }
+}
diff --git a/microsoft/knowledge/web-services/webhook-eligibility-and-validationtoken-renewal.bad.js b/microsoft/knowledge/web-services/webhook-eligibility-and-validationtoken-renewal.bad.js
new file mode 100644
index 0000000..1f624f0
--- /dev/null
+++ b/microsoft/knowledge/web-services/webhook-eligibility-and-validationtoken-renewal.bad.js
@@ -0,0 +1,4 @@
+function receiveBusinessCentralWebhook(request, response) {
+ processNotifications(request.body.value);
+ response.sendStatus(200);
+}
diff --git a/microsoft/knowledge/web-services/webhook-eligibility-and-validationtoken-renewal.good.al b/microsoft/knowledge/web-services/webhook-eligibility-and-validationtoken-renewal.good.al
new file mode 100644
index 0000000..0f673cb
--- /dev/null
+++ b/microsoft/knowledge/web-services/webhook-eligibility-and-validationtoken-renewal.good.al
@@ -0,0 +1,28 @@
+page 50354 "WS Webhook Customer API"
+{
+ PageType = API;
+ APIPublisher = 'contoso';
+ APIGroup = 'sales';
+ APIVersion = 'v1.0';
+ EntityName = 'webhookCustomer';
+ EntitySetName = 'webhookCustomers';
+ ODataKeyFields = SystemId;
+ SourceTable = Customer;
+
+ layout
+ {
+ area(content)
+ {
+ repeater(records)
+ {
+ field(id; Rec.SystemId)
+ {
+ Editable = false;
+ }
+ field(displayName; Rec.Name)
+ {
+ }
+ }
+ }
+ }
+}
diff --git a/microsoft/knowledge/web-services/webhook-eligibility-and-validationtoken-renewal.good.js b/microsoft/knowledge/web-services/webhook-eligibility-and-validationtoken-renewal.good.js
new file mode 100644
index 0000000..f2e42fd
--- /dev/null
+++ b/microsoft/knowledge/web-services/webhook-eligibility-and-validationtoken-renewal.good.js
@@ -0,0 +1,11 @@
+function receiveBusinessCentralWebhook(request, response) {
+ const validationToken = request.query.validationToken;
+
+ if (typeof validationToken === "string") {
+ response.status(200).type("text/plain").send(validationToken);
+ return;
+ }
+
+ processNotifications(request.body.value);
+ response.sendStatus(200);
+}
diff --git a/microsoft/knowledge/web-services/webhook-eligibility-and-validationtoken-renewal.md b/microsoft/knowledge/web-services/webhook-eligibility-and-validationtoken-renewal.md
new file mode 100644
index 0000000..b35a05c
--- /dev/null
+++ b/microsoft/knowledge/web-services/webhook-eligibility-and-validationtoken-renewal.md
@@ -0,0 +1,30 @@
+---
+bc-version: [all]
+domain: web-services
+keywords: [webhook, subscription, validationtoken, expirationdatetime, webhook-supported-resources, api-page, sourcetabletemporary, querytype]
+technologies: [al, javascript]
+countries: [w1]
+application-area: [all]
+---
+
+# Verify webhook eligibility and complete every validationToken handshake
+
+## Description
+
+Business Central can subscribe only to eligible API pages, not every endpoint that can be read through an API. Webhooks exclude API queries, temporary API pages, pages with composite OData keys, pages over system tables, and pages over Job Queue Entry (table 472); the environment's `webhookSupportedResources` endpoint is authoritative. Creating and renewing a subscription both call the `notificationUrl` with `validationToken`, and both fail unless the subscriber returns that token in the response body with `200 OK`.
+
+## Best Practice
+
+Before creating a subscription, confirm the resource appears in `webhookSupportedResources` and that a custom endpoint is an API page with a single stable key over an eligible persistent table. Use one validation path that echoes `validationToken` for both create (`POST`) and renew (`PATCH`) handshakes. Track `expirationDateTime` and renew before expiry: online subscriptions expire after three days, while on-premises lifetime defaults to three days and can be changed with `ApiSubscriptionExpiration`.
+
+See samples: `webhook-eligibility-and-validationtoken-renewal.good.al` and `webhook-eligibility-and-validationtoken-renewal.good.js`.
+
+## Anti Pattern
+
+Attempting to subscribe to an API query, temporary/composite/system-table/Job Queue Entry API page, or assuming a successful create handshake makes renewal automatic. Composite includes an explicit multi-field `ODataKeyFields` and a missing `ODataKeyFields` when the source table's primary key has multiple fields. A renewal issues the same validation challenge; a notification handler that ignores the query-string token cannot create or renew the subscription.
+
+See samples: `webhook-eligibility-and-validationtoken-renewal.bad.al` and `webhook-eligibility-and-validationtoken-renewal.bad.js`.
+
+## Source
+
+[Working with webhooks](https://learn.microsoft.com/dynamics365/business-central/dev-itpro/api-reference/v2.0/dynamics-subscriptions) and [Update subscriptions](https://learn.microsoft.com/dynamics365/business-central/dev-itpro/api-reference/v2.0/api/dynamics_subscriptions_update).
diff --git a/microsoft/skills/review/al-appsource-review.md b/microsoft/skills/review/al-appsource-review.md
new file mode 100644
index 0000000..cf09619
--- /dev/null
+++ b/microsoft/skills/review/al-appsource-review.md
@@ -0,0 +1,129 @@
+---
+kind: action-skill
+id: al-appsource-review
+version: 1
+title: AL AppSource review
+description: Performs an AL AppSource review against source and app metadata guidance from BCQuality.
+inputs: [pr-diff, file-path]
+outputs: [findings-report]
+bc-version: [all]
+technologies: [al]
+countries: [w1]
+application-area: [all]
+---
+
+# AL AppSource review
+
+Reviews AL source and app metadata changes against the `appsource` 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). AppSource findings are narrow by design β they apply when the diff touches AppSourceCop configuration, AL object or extension-member names, or AppSource-facing `app.json` metadata. The skill returns `not-applicable` when none of those apply.
+
+## Source
+
+Read the BCQuality knowledge index once β the `knowledge-index.json` BCQuality builds at the root of the knowledge checkout (Entry's preparation step regenerates it over the live, already-filtered clone β see `skills/entry.md`). It lists every article that survived layer and allow/deny filtering and carries, per article, its `path`, `layer`, `domain`, frontmatter dimensions, `keywords`, `title`, and a one-line `description` hint β exactly the fields Relevance and Worklist consume. Take the index entries whose `domain` is `appsource` as this skill's candidate set across every enabled Microsoft, community, and custom layer; do not open the individual article files at this step. Open an article's full body only once it enters the Worklist below, so a review reads the index plus the handful of worklisted articles instead of every file under `*/knowledge/appsource/**`.
+
+## 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 files and AL object types β especially `app.json`, `AppSourceCop.json`, new objects, and table/page/report extensions that add fields, keys, controls, or actions to base objects.
+- The changed object and member names, weighted toward prefix/suffix consistency with `mandatoryAffixes` or `mandatoryPrefix`, plus AppSource-facing help metadata.
+- Tokens extracted from the diff that relate to AppSource (`AppSourceCop`, `mandatoryAffixes`, `mandatoryPrefix`, `AS0011`, `prefix`, `suffix`, `tableextension`, `pageextension`, `reportextension`, `field`, `key`, `control`, `action`, `app.json`, `help`, `ContextSensitiveHelpPage`, `Copilot`, `https`).
+
+A file enters the candidate worklist when its `keywords` intersect the extracted tokens or its topic (derived from the index entry's `path`, `title`, and `description`) matches a changed object type. Read an article's full file β its `## Best Practice` / `## Anti Pattern` bodies β only after it makes the worklist; candidate selection uses the index alone. When the diff contains no AppSource-related source or metadata changes by any of the above signals, return `outcome: "not-applicable"` without evaluating files.
+
+The following targeted checks cover every current `appsource` article across the Microsoft and community layers. Treat each as a candidate-selection cue: when the signal appears in changed code, add the named article to the worklist and evaluate it in Action.
+
+- A new or renamed object lacks the reserved prefix/suffix, or a tableextension/pageextension/reportextension adds an unaffixed field, key, control, or action to a base object despite `mandatoryAffixes`/`mandatoryPrefix` and AS0011 β `object-affixes-prevent-collisions`.
+- For BC v27 or later, `app.json` adds or changes the `help` URL to a path deeper than two levels, or a changed Copilot/context-sensitive help arrangement would ground the app under an overly broad truncated parent β `keep-copilot-help-url-to-two-path-levels`.
+
+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 AppSource knowledge exists, or because configuration suppressed every candidate, emit `outcome: "no-knowledge"`. When the worklist is empty because no applicable AppSource 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 change violates an AppSource submission requirement; otherwise 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.
+
+Set `confidence` to:
+
+- `high` when the detection is based on an unambiguous pattern match (affix configuration/name or URL path depth).
+- `medium` when detection relies on heuristics or when any frontmatter dimension was `unknown`.
+- `low` when the finding is an advisory derived only from applicability.
+
+After evaluating each worklist entry, also consider whether the diff exhibits an AppSource defect the agent recognises from its general AL knowledge that no knowledge file in the worklist covers. Such candidates are agent findings within this skill's domain β emit them with `references: []`, an `id` slug prefixed with `agent:`, `confidence` capped at `medium`, `severity` capped at `minor` (agent findings are advisory and non-gating), and a `message` that is self-contained (describing both the issue and a concrete recommendation, since there is no knowledge-file footer for the consumer to fall back on). Hold every candidate to the precision bar in `skills/do.md` (*Agent findings*): emit only a concrete, material AppSource defect a knowledgeable BC reviewer would agree is wrong β steelman it first and drop anything stylistic, speculative, dependent on code outside the diff, or merely a valid alternative; when in doubt, omit. The scope is strictly AppSource; defects outside this domain belong to other leaves and MUST NOT be emitted here. Before emitting, check the worklist for a knowledge file that matches the candidate β if one exists, upgrade the candidate to a knowledge-backed finding instead. See `skills/do.md` for the full contract.
+
+For every emitted finding, decide whether the fix is mechanical. A fix is mechanical when it is small, local, and unambiguous from the diff context (for example: add the configured affix to one object or extension member, or replace a deep help URL with a known two-level canonical URL). For mechanical findings, emit `findings[].suggested-code` with the literal replacement for the source lines indicated by `location`. The payload must be a verbatim replacement β no diff markers, no fences, no commentary β that the consumer can render as a one-click suggestion. When a `.good.al` companion exists and the diff context matches the `.bad.al` shape, adapt the `.good.al` replacement into `suggested-code`.
+
+Omit `suggested-code` only when the appropriate fix depends on context the skill cannot determine, when multiple defensible replacements exist, or when the fix spans non-contiguous code. If a finding is mechanical-looking but you omit `suggested-code`, set `findings[].suggested-code-omission-reason` to a short explanation. See `skills/do.md` for the full contract.
+
+Outcome selection:
+
+- `completed` β the skill evaluated every worklist item.
+- `no-knowledge` β no applicable AppSource knowledge survived filtering.
+- `not-applicable` β the diff touches no AppSource source, analyzer configuration, or app-metadata surface.
+- `partial` β a budget was hit before the worklist was exhausted.
+- `failed` β an unrecoverable error occurred.
+
+## Output
+
+Output conforms to the DO output contract. Every finding this skill emits MUST set `findings[].domain` to `"AppSource"`. A populated example:
+
+```json
+{
+ "skill": { "id": "al-appsource-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/appsource/object-affixes-prevent-collisions.md",
+ "severity": "major",
+ "message": "The tableextension adds an unaffixed Loyalty Points field to Customer, so it violates the configured AppSource affix and can collide with another extension.",
+ "location": {
+ "file": "src/CustomerExt.TableExt.al",
+ "line": 8
+ },
+ "references": [
+ { "path": "microsoft/knowledge/appsource/object-affixes-prevent-collisions.md" }
+ ],
+ "confidence": "high",
+ "domain": "AppSource",
+ "suggested-code": "field(50100; \"Loyalty Points ABC\"; Integer)"
+ }
+ ],
+ "suppressed": []
+}
+```
+
+The empty-corpus case produces:
+
+```json
+{
+ "skill": { "id": "al-appsource-review", "version": 1 },
+ "outcome": "no-knowledge",
+ "summary": {
+ "counts": { "blocker": 0, "major": 0, "minor": 0, "info": 0 },
+ "coverage": { "worklist-size": 0, "items-evaluated": 0 }
+ },
+ "findings": [],
+ "suppressed": []
+}
+```
diff --git a/microsoft/skills/review/al-code-review.md b/microsoft/skills/review/al-code-review.md
index f01bac9..a7cb8d9 100644
--- a/microsoft/skills/review/al-code-review.md
+++ b/microsoft/skills/review/al-code-review.md
@@ -22,6 +22,10 @@ sub-skills:
- microsoft/skills/review/al-interfaces-review.md
- microsoft/skills/review/al-breaking-changes-review.md
- microsoft/skills/review/al-web-services-review.md
+ - microsoft/skills/review/al-testing-review.md
+ - microsoft/skills/review/al-data-modeling-review.md
+ - microsoft/skills/review/al-appsource-review.md
+ - microsoft/skills/review/al-telemetry-review.md
---
# AL code review
@@ -34,7 +38,7 @@ An orchestrator invokes this skill with either a `pr-diff` (the standard PR-revi
## Source
-The sub-skills invoked by this skill are those listed in frontmatter `sub-skills`. Additional leaf skills (for example, telemetry, testing) are added by updating the `sub-skills` list. The skill does not discover sub-skills implicitly.
+The sub-skills invoked by this skill are those listed in frontmatter `sub-skills`. Additional leaf skills are added by updating the `sub-skills` list. The skill does not discover sub-skills implicitly.
## Relevance
@@ -72,7 +76,7 @@ For each sub-skill in the worklist, executed one at a time per the discipline ab
1. Invoke the sub-skill with the orchestrator's inputs, passing only the subset each sub-skill declares in its `inputs`.
2. Capture the sub-skill's complete findings-report verbatim and append it to `sub-results`.
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` and copying each finding's `domain` field verbatim (preserve it unchanged). 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.
+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` and preserving each finding's optional `domain` field verbatim, including its absence. 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.
### Agent self-review pass
@@ -85,7 +89,7 @@ Frame the pass by cross-cutting concerns β architecture, error handling, resou
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 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, set `domain` to the human-readable label required by that sub-skill's Output contract, 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 a super-skill agent finding.
2. **Emit agent finding.** Per DO's *Agent findings* rules:
@@ -128,38 +132,38 @@ Output conforms to the DO output contract, extended with `sub-results` and `skip
},
"findings": [
{
- "id": "microsoft/knowledge/performance/filter-before-find.md",
+ "id": "microsoft/knowledge/performance/apply-filters-before-iterating.md",
"severity": "major",
- "message": "FindSet is called on a record variable without any prior SetRange/SetFilter. This forces a full-table scan.",
+ "message": "The Country/Region Code predicate is evaluated inside the loop instead of with SetRange before FindSet, so every row crosses the database boundary.",
"location": {
"file": "src/Sales/PostingRoutines.Codeunit.al",
"line": 140,
"range": { "start-line": 140, "end-line": 144 }
},
"references": [
- { "path": "microsoft/knowledge/performance/filter-before-find.md" }
+ { "path": "microsoft/knowledge/performance/apply-filters-before-iterating.md" }
],
"confidence": "high",
"from-sub-skill": "al-performance-review",
"domain": "Performance"
},
{
- "id": "community/knowledge/performance/call-setloadfields-before-filters.md",
+ "id": "microsoft/knowledge/performance/use-setloadfields-for-partial-records.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.",
+ "message": "The loop reads only a small subset of fields from a wide table without SetLoadFields, transferring every column for each row.",
"location": {
"file": "src/Sales/PostingRoutines.Codeunit.al",
"line": 152
},
"references": [
- { "path": "community/knowledge/performance/call-setloadfields-before-filters.md" }
+ { "path": "microsoft/knowledge/performance/use-setloadfields-for-partial-records.md" }
],
"confidence": "high",
"from-sub-skill": "al-performance-review",
"domain": "Performance"
},
{
- "id": "microsoft/knowledge/security/use-secrettext-for-credentials.md",
+ "id": "microsoft/knowledge/security/secrettext-for-credentials.md",
"severity": "blocker",
"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": {
@@ -168,22 +172,22 @@ Output conforms to the DO output contract, extended with `sub-results` and `skip
"range": { "start-line": 85, "end-line": 89 }
},
"references": [
- { "path": "microsoft/knowledge/security/use-secrettext-for-credentials.md" }
+ { "path": "microsoft/knowledge/security/secrettext-for-credentials.md" }
],
"confidence": "high",
"from-sub-skill": "al-security-review",
"domain": "Security"
},
{
- "id": "microsoft/knowledge/security/never-hardcode-secrets-in-al.md",
+ "id": "microsoft/knowledge/security/secrets-isolated-storage.md",
"severity": "minor",
- "message": "An API key is assigned from a string literal rather than retrieved from IsolatedStorage or Key Vault at runtime.",
+ "message": "A setup table stores an API key in an ordinary Text field, exposing it through table reads and exports. Persist it in IsolatedStorage instead.",
"location": {
- "file": "src/Integration/ApiClient.Codeunit.al",
- "line": 201
+ "file": "src/Integration/ExternalServiceSetup.Table.al",
+ "line": 12
},
"references": [
- { "path": "microsoft/knowledge/security/never-hardcode-secrets-in-al.md" }
+ { "path": "microsoft/knowledge/security/secrets-isolated-storage.md" }
],
"confidence": "medium",
"from-sub-skill": "al-security-review",
@@ -215,30 +219,30 @@ Output conforms to the DO output contract, extended with `sub-results` and `skip
},
"findings": [
{
- "id": "microsoft/knowledge/performance/filter-before-find.md",
+ "id": "microsoft/knowledge/performance/apply-filters-before-iterating.md",
"severity": "major",
- "message": "FindSet is called on a record variable without any prior SetRange/SetFilter. This forces a full-table scan.",
+ "message": "The Country/Region Code predicate is evaluated inside the loop instead of with SetRange before FindSet, so every row crosses the database boundary.",
"location": {
"file": "src/Sales/PostingRoutines.Codeunit.al",
"line": 140,
"range": { "start-line": 140, "end-line": 144 }
},
"references": [
- { "path": "microsoft/knowledge/performance/filter-before-find.md" }
+ { "path": "microsoft/knowledge/performance/apply-filters-before-iterating.md" }
],
"confidence": "high",
"domain": "Performance"
},
{
- "id": "community/knowledge/performance/call-setloadfields-before-filters.md",
+ "id": "microsoft/knowledge/performance/use-setloadfields-for-partial-records.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.",
+ "message": "The loop reads only a small subset of fields from a wide table without SetLoadFields, transferring every column for each row.",
"location": {
"file": "src/Sales/PostingRoutines.Codeunit.al",
"line": 152
},
"references": [
- { "path": "community/knowledge/performance/call-setloadfields-before-filters.md" }
+ { "path": "microsoft/knowledge/performance/use-setloadfields-for-partial-records.md" }
],
"confidence": "high",
"domain": "Performance"
@@ -255,7 +259,7 @@ Output conforms to the DO output contract, extended with `sub-results` and `skip
},
"findings": [
{
- "id": "microsoft/knowledge/security/use-secrettext-for-credentials.md",
+ "id": "microsoft/knowledge/security/secrettext-for-credentials.md",
"severity": "blocker",
"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": {
@@ -264,21 +268,21 @@ Output conforms to the DO output contract, extended with `sub-results` and `skip
"range": { "start-line": 85, "end-line": 89 }
},
"references": [
- { "path": "microsoft/knowledge/security/use-secrettext-for-credentials.md" }
+ { "path": "microsoft/knowledge/security/secrettext-for-credentials.md" }
],
"confidence": "high",
"domain": "Security"
},
{
- "id": "microsoft/knowledge/security/never-hardcode-secrets-in-al.md",
+ "id": "microsoft/knowledge/security/secrets-isolated-storage.md",
"severity": "minor",
- "message": "An API key is assigned from a string literal rather than retrieved from IsolatedStorage or Key Vault at runtime.",
+ "message": "A setup table stores an API key in an ordinary Text field, exposing it through table reads and exports. Persist it in IsolatedStorage instead.",
"location": {
- "file": "src/Integration/ApiClient.Codeunit.al",
- "line": 201
+ "file": "src/Integration/ExternalServiceSetup.Table.al",
+ "line": 12
},
"references": [
- { "path": "microsoft/knowledge/security/never-hardcode-secrets-in-al.md" }
+ { "path": "microsoft/knowledge/security/secrets-isolated-storage.md" }
],
"confidence": "medium",
"domain": "Security"
@@ -320,4 +324,3 @@ The empty-corpus case β BCQuality's state until knowledge files land β rolls
]
}
```
-
diff --git a/microsoft/skills/review/al-data-modeling-review.md b/microsoft/skills/review/al-data-modeling-review.md
new file mode 100644
index 0000000..ca10e73
--- /dev/null
+++ b/microsoft/skills/review/al-data-modeling-review.md
@@ -0,0 +1,132 @@
+---
+kind: action-skill
+id: al-data-modeling-review
+version: 1
+title: AL data-modeling review
+description: Performs an AL data-modeling review against guidance from BCQuality.
+inputs: [pr-diff, file-path]
+outputs: [findings-report]
+bc-version: [all]
+technologies: [al]
+countries: [w1]
+application-area: [all]
+---
+
+# AL data-modeling review
+
+Reviews AL source changes against the `data-modeling` 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). Data-modeling findings are narrow by design β they apply when the diff touches setup or master tables, their card pages, primary keys, number-series assignment, block enforcement, or audit fields. The skill returns `not-applicable` when none of those apply.
+
+## Source
+
+Read the BCQuality knowledge index once β the `knowledge-index.json` BCQuality builds at the root of the knowledge checkout (Entry's preparation step regenerates it over the live, already-filtered clone β see `skills/entry.md`). It lists every article that survived layer and allow/deny filtering and carries, per article, its `path`, `layer`, `domain`, frontmatter dimensions, `keywords`, `title`, and a one-line `description` hint β exactly the fields Relevance and Worklist consume. Take the index entries whose `domain` is `data-modeling` as this skill's candidate set across every enabled layer; do not open the individual article files at this step. Open an article's full body only once it enters the Worklist below, so a review reads the index plus the handful of worklisted articles instead of every file under `*/knowledge/data-modeling/**`.
+
+## 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 `* Setup` singleton tables and Card pages, custom master tables, tableextensions that add master-data fields, and document or journal lines that reference a master.
+- The changed fields, keys, triggers, and procedures, weighted toward `Primary Key`, `No.`, `No. Series`, `Blocked`, `Last Date Modified`, `OnInsert`, `OnModify`, `OnRename`, reference-field `OnValidate`, and posting validation.
+- Tokens extracted from the diff that relate to data modeling (`setup`, `master`, `Primary Key`, `Code[10]`, `Code[20]`, `AutoIncrement`, `SystemId`, `No.`, `No. Series`, `NoSeriesManagement`, `Codeunit "No. Series"`, `GetNextNo`, `IsManual`, `TestManual`, `Blocked`, `TestField`, `Last Date Modified`, `Today`, `WorkDate`, `InsertAllowed`, `DeleteAllowed`, `PageType = Card`, `OnOpenPage`, `GetRecordOnce`, `OnInsert`, `OnModify`, `OnRename`).
+
+A file enters the candidate worklist when its `keywords` intersect the extracted tokens or its topic (derived from the index entry's `path`, `title`, and `description`) matches a changed object type. Read an article's full file β its `## Best Practice` / `## Anti Pattern` bodies β only after it makes the worklist; candidate selection uses the index alone. When the diff contains no data-modeling changes by any of the above signals, return `outcome: "not-applicable"` without evaluating files.
+
+The following targeted checks cover every current `data-modeling` article. Treat each as a candidate-selection cue: when the signal appears in changed code, add the named article to the worklist and evaluate it in Action.
+
+- A `* Setup` table or its page changes singleton structure, uses a nonblank or generated key, permits insert/delete, uses a List page, or does not ensure the blank-keyed row exists β `setup-table-is-a-singleton`.
+- A custom master table changes its primary key, `No.`/`No. Series` fields, or `OnInsert` without assigning a blank `No.` from setup through a number series β `master-table-no-from-number-series-in-oninsert`.
+- BC v22 or later code introduces or retains `NoSeriesManagement`, `InitSeries`, `SelectSeries`, or `SetSeries`, or number assignment/manual-entry checks do not use codeunit `"No. Series"` methods such as `GetNextNo`, `IsManual`, or `TestManual` β `use-no-series-codeunit-not-noseriesmanagement`.
+- A master gains or changes `Blocked`, or a document line, journal line, reference-field `OnValidate`, or posting routine uses that master without `TestField(Blocked, false)` at the point of use; also cue when the check is placed only in the master's own triggers β `check-blocked-in-referencing-code-not-in-master`.
+- A master table adds or changes `Last Date Modified`, `OnModify`, or `OnRename`, but the non-editable field is not assigned `Today()` in both triggers β `set-last-date-modified-in-onmodify-and-onrename`.
+
+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 data-modeling knowledge exists, or because configuration suppressed every candidate, emit `outcome: "no-knowledge"`. When the worklist is empty because no applicable data-modeling 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 model can create ambiguous setup state, incompatible business identifiers, or silently stale synchronization data; otherwise 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.
+
+Set `confidence` to:
+
+- `high` when the detection is based on an unambiguous pattern match (object type, field, key, trigger, or API name).
+- `medium` when detection relies on heuristics or when any frontmatter dimension was `unknown`.
+- `low` when the finding is an advisory derived only from applicability.
+
+After evaluating each worklist entry, also consider whether the diff exhibits a data-modeling defect the agent recognises from its general AL knowledge that no knowledge file in the worklist covers. Such candidates are agent findings within this skill's domain β emit them with `references: []`, an `id` slug prefixed with `agent:`, `confidence` capped at `medium`, `severity` capped at `minor` (agent findings are advisory and non-gating), and a `message` that is self-contained (describing both the issue and a concrete recommendation, since there is no knowledge-file footer for the consumer to fall back on). Hold every candidate to the precision bar in `skills/do.md` (*Agent findings*): emit only a concrete, material data-modeling defect a knowledgeable BC reviewer would agree is wrong β steelman it first and drop anything stylistic, speculative, dependent on code outside the diff, or merely a valid alternative; when in doubt, omit. The scope is strictly data modeling; defects outside this domain belong to other leaves and MUST NOT be emitted here. Before emitting, check the worklist for a knowledge file that matches the candidate β if one exists, upgrade the candidate to a knowledge-backed finding instead. See `skills/do.md` for the full contract.
+
+For every emitted finding, decide whether the fix is mechanical. A fix is mechanical when it is small, local, and unambiguous from the diff context (for example: add `InsertAllowed = false` or `DeleteAllowed = false`; replace `WorkDate()` with `Today()`; add the same audit-field assignment to `OnRename`; or replace an obsolete number-series codeunit declaration). For mechanical findings, emit `findings[].suggested-code` with the literal replacement for the source lines indicated by `location`. The payload must be a verbatim replacement β no diff markers, no fences, no commentary β that the consumer can render as a one-click suggestion. When a `.good.al` companion exists and the diff context matches the `.bad.al` shape, adapt the `.good.al` replacement into `suggested-code`.
+
+Omit `suggested-code` only when the appropriate fix depends on context the skill cannot determine, when multiple defensible replacements exist, or when the fix spans non-contiguous code. If a finding is mechanical-looking but you omit `suggested-code`, set `findings[].suggested-code-omission-reason` to a short explanation. See `skills/do.md` for the full contract.
+
+Outcome selection:
+
+- `completed` β the skill evaluated every worklist item.
+- `no-knowledge` β no applicable data-modeling knowledge survived filtering.
+- `not-applicable` β the diff touches no setup/master table, page, key, numbering, block-check, or audit-field surface.
+- `partial` β a budget was hit before the worklist was exhausted.
+- `failed` β an unrecoverable error occurred.
+
+## Output
+
+Output conforms to the DO output contract. Every finding this skill emits MUST set `findings[].domain` to `"Data Modeling"`. A populated example:
+
+```json
+{
+ "skill": { "id": "al-data-modeling-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/data-modeling/set-last-date-modified-in-onmodify-and-onrename.md",
+ "severity": "major",
+ "message": "The table updates Last Date Modified in OnModify but not OnRename, so renaming the primary key leaves the audit date stale and can hide the record from incremental integrations.",
+ "location": {
+ "file": "src/LoyaltyMember.Table.al",
+ "line": 74
+ },
+ "references": [
+ { "path": "microsoft/knowledge/data-modeling/set-last-date-modified-in-onmodify-and-onrename.md" }
+ ],
+ "confidence": "high",
+ "domain": "Data Modeling",
+ "suggested-code": "trigger OnRename()\nbegin\n \"Last Date Modified\" := Today();\nend;"
+ }
+ ],
+ "suppressed": []
+}
+```
+
+The empty-corpus case produces:
+
+```json
+{
+ "skill": { "id": "al-data-modeling-review", "version": 1 },
+ "outcome": "no-knowledge",
+ "summary": {
+ "counts": { "blocker": 0, "major": 0, "minor": 0, "info": 0 },
+ "coverage": { "worklist-size": 0, "items-evaluated": 0 }
+ },
+ "findings": [],
+ "suppressed": []
+}
+```
diff --git a/microsoft/skills/review/al-error-handling-review.md b/microsoft/skills/review/al-error-handling-review.md
index 4f1b729..3b7d6cf 100644
--- a/microsoft/skills/review/al-error-handling-review.md
+++ b/microsoft/skills/review/al-error-handling-review.md
@@ -38,8 +38,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 codeunits that post or validate, tables and table extensions with `OnValidate` triggers, and any procedure that raises errors or orchestrates a batch over records.
-- The changed procedures and triggers, weighted toward `OnValidate`/`OnInsert`/`OnModify` triggers, posting and validation routines, and procedures attributed with `[ErrorBehavior(...)]`.
-- Tokens extracted from the diff that relate to error surfacing and diagnostics (`Error`, `ErrorInfo`, `Title`, `Message`, `DetailedMessage`, `AddAction`, `AddNavigationAction`, `RecordId`, `PageNo`, `ErrorBehavior`, `Collect`, `HasCollectedErrors`, `GetCollectedErrors`, `ClearCollectedErrors`, `ErrorType`, `Internal`, `Client`).
+- The changed procedures and triggers, weighted toward `OnValidate`/`OnInsert`/`OnModify` triggers, posting and validation routines, and procedures attributed with `[ErrorBehavior(...)]` or `[TryFunction]`.
+- Tokens extracted from the diff that relate to error surfacing and diagnostics (`Error`, `ErrorInfo`, `FieldError`, `TestField`, `Title`, `Message`, `DetailedMessage`, `AddAction`, `AddNavigationAction`, `RecordId`, `PageNo`, `ErrorBehavior`, `Collect`, `HasCollectedErrors`, `GetCollectedErrors`, `ClearCollectedErrors`, `ErrorType`, `Internal`, `Client`, `TryFunction`, `GetLastErrorText`, Boolean assignment).
+- Resolve changed standalone call targets; when the target declaration has `[TryFunction]`, worklist the ignored-return rule even if the declaration itself is unchanged. Only assignment and conditional use activate try semantics.
A file enters the candidate worklist when its `keywords` intersect the extracted tokens or its topic (derived from the index entry's `path`, `title`, and `description`) matches a changed object type. Read an article's full file β its `## Best Practice` / `## Anti Pattern` bodies β only after it makes the worklist; candidate selection uses the index alone.
diff --git a/microsoft/skills/review/al-events-review.md b/microsoft/skills/review/al-events-review.md
index 811e6f3..ef4be0e 100644
--- a/microsoft/skills/review/al-events-review.md
+++ b/microsoft/skills/review/al-events-review.md
@@ -39,7 +39,7 @@ Narrow the relevant files to the subset that applies to the changes under review
- The changed AL object names and types β especially codeunits that publish events or host event subscribers, posting/release/validation routines that should expose extension points, and test codeunits that bind subscribers.
- The changed procedures and triggers, weighted toward event publisher methods, methods carrying the `[EventSubscriber(...)]` attribute, routines that raise `OnBefore`/`OnAfter` events, and any procedure that calls `BindSubscription`/`UnbindSubscription`.
-- Tokens extracted from the diff that relate to events and the publish/subscribe model (`IntegrationEvent`, `BusinessEvent`, `EventSubscriber`, `IsHandled`, `BindSubscription`, `UnbindSubscription`, `EventSubscriberInstance`, `OnBefore`, `OnAfter`, `Manual`, `IncludeSender`, `Sender`, `this`, `RecordRef`, `xRec`, `temporary`, `Temp`, `repeat`).
+- Tokens extracted from the diff that relate to events and the publish/subscribe model (`IntegrationEvent`, `BusinessEvent`, `InternalEvent`, `EventSubscriber`, `IsHandled`, `BindSubscription`, `UnbindSubscription`, `EventSubscriberInstance`, `OnBefore`, `OnAfter`, `Manual`, `IncludeSender`, `GlobalVarAccess`, `Isolated`, `local`, `internal`, `Sender`, `this`, `RecordRef`, `xRec`, `temporary`, `Temp`, `repeat`).
A file enters the candidate worklist when its `keywords` intersect the extracted tokens or its topic (derived from the index entry's `path`, `title`, and `description`) matches a changed object type. Read an article's full file β its `## Best Practice` / `## Anti Pattern` bodies β only after it makes the worklist; candidate selection uses the index alone.
@@ -53,13 +53,15 @@ The following targeted checks map diff signals to specific `events` articles. Tr
- `IsHandled` raised without an immediately preceding `IsHandled := false;`, or one `IsHandled` variable reused across several raises with no reset between them β `initialize-ishandled-to-false-before-publishing`.
- `if IsHandled then exit;` in a routine that also raises a paired `OnAfterβ¦` event later, so the after-event is skipped whenever the call is handled β `preserve-onafter-execution-when-ishandled-skips-the-body`.
-- A parameter added before existing parameters on a changed event signature instead of appended at the end β `add-new-event-parameters-at-the-end`.
+- Any parameter added to a public Business/Integration event procedure, regardless of position; do not flag additions or reordering on `local`/`internal` publishers merely because a new parameter was not appended β `add-new-event-parameters-at-the-end`.
+- A shipped Business/Integration event renamed or removed, or an existing parameter renamed, removed, retyped, or changed to/from `var`, based on the mistaken assumption that `local` or `internal` prevents dependent subscription; parameter order alone is not a subscriber-contract violation β `treat-local-and-internal-events-as-subscriber-contracts`.
+- Any change to `IncludeSender` or `GlobalVarAccess` on a shipped event at any target version, or to `Isolated` on BC20/runtime 9.0 or later, including a change intended to modernize the publisher β `do-not-change-shipped-event-attribute-flags`.
- Publisher names that do not encode firing position (`OnBefore`/`OnAfter` at the boundaries, `OnOnBefore`/`OnAfter` mid-routine) β `name-events-by-publisher-position`.
- Two consecutive `OnBefore`/`OnAfter` raises with no logic between them, or a near-duplicate event differing only by an extra parameter β `prefer-reusing-or-extending-existing-events`.
- An event raised between `repeat` and `until` inside a record loop β `do-not-publish-events-inside-loops`.
- A `temporary` record event parameter whose name does not start with `Temp` β `prefix-temporary-record-event-parameters-with-temp`.
- Abbreviated event parameter names (`SalesHdr`, `DocNo`, `Amt`) instead of full table names and spelled-out values β `name-event-parameters-without-abbreviations`.
-- `[IntegrationEvent(true, β¦)]` (`IncludeSender`) on a codeunit event used only to expose the publisher, where `this` could be passed as a typed `Sender` parameter (Business Central 2024 release wave 2 and later) β `prefer-this-over-includesender-in-codeunit-events`.
+- `[IntegrationEvent(true, β¦)]` (`IncludeSender`) on a newly added codeunit event used only to expose the publisher, where `this` could be passed as a typed `Sender` parameter (Business Central 2024 release wave 2 and later) β `prefer-this-over-includesender-in-codeunit-events`.
- A `RecordRef` event parameter, or a passed-through `xRec`, where a concrete typed record fits β `avoid-loosely-typed-event-parameters`.
- A `var IsHandled` added to a pre-existing event rather than introduced through a new `OnBefore` publisher β `do-not-add-ishandled-to-an-existing-event`.
- An `if IsHandled then exit;` whose skipped body performs posting, ledger-entry creation, number-series consumption, or integrity/permission validation β `do-not-bypass-critical-operations-with-ishandled`.
diff --git a/microsoft/skills/review/al-interfaces-review.md b/microsoft/skills/review/al-interfaces-review.md
index 6fbb59b..a52d451 100644
--- a/microsoft/skills/review/al-interfaces-review.md
+++ b/microsoft/skills/review/al-interfaces-review.md
@@ -39,7 +39,7 @@ Narrow the relevant files to the subset that applies to the changes under review
- The changed AL object names and types β especially `interface` objects, codeunits and enums declared with the `implements` keyword, and consumers that declare or assign an `Interface` variable.
- The changed procedures and triggers, weighted toward factory or dispatch routines that resolve a variant to behaviour, setter-injection procedures that take an `Interface` parameter, and `case`-over-enum blocks that select between strategies.
-- Tokens extracted from the diff that relate to interfaces and enum-backed implementation (`interface`, `implements`, `Implementation`, `DefaultImplementation`, `UnknownValueImplementation`, `enum`, `Extensible`, `Interface`, `case`, and the `case of` anti-pattern signal β a `case` over an enum value whose branches choose between variant computations).
+- Tokens extracted from the diff that relate to interfaces and enum-backed implementation (`interface`, `extends`, `implements`, `Implementation`, `DefaultImplementation`, `UnknownValueImplementation`, `enum`, `Extensible`, `Interface`, `case`, and the `case of` anti-pattern signal β a `case` over an enum value whose branches choose between variant computations).
A file enters the candidate worklist when its `keywords` intersect the extracted tokens or its topic (derived from the index entry's `path`, `title`, and `description`) matches a changed object type. Read an article's full file β its `## Best Practice` / `## Anti Pattern` bodies β only after it makes the worklist; candidate selection uses the index alone.
@@ -47,6 +47,14 @@ Once the candidate worklist is known, resolve layer-precedence conflicts per REA
When the post-conflict worklist is empty because no applicable interfaces knowledge exists, or because configuration suppressed every candidate, emit `outcome: "no-knowledge"`. When the worklist is empty because no applicable interfaces knowledge matched the changes, emit `outcome: "completed"` with an empty `findings` array.
+### Interface-compatibility checks
+
+The following targeted checks map diff signals to specific `interfaces` articles. Treat each as a candidate-selection cue: when the signal appears in the changed code, add the named article to the worklist and evaluate it in Action.
+
+- `DefaultImplementation` used as the only fallback where a persisted ordinal may no longer match any declared enum value, or a persisted enum lacks `UnknownValueImplementation` on BC18 or later β `handle-unknown-enum-ordinals-with-unknownvalueimplementation`.
+- A method added directly to an interface that exists in the baseline, instead of adding a BC25+ interface that `extends` it or a versioned sibling for older targets β `extend-published-interfaces-dont-edit-them`.
+- A declared enum value with no `Implementation` and no enum-level `DefaultImplementation` β `set-defaultimplementation-on-enum`.
+
## Action
For each worklist entry, evaluate the diff against the file's `## Best Practice` and `## Anti Pattern` sections. Emit findings as follows:
diff --git a/microsoft/skills/review/al-performance-review.md b/microsoft/skills/review/al-performance-review.md
index 3c99b83..f27beb3 100644
--- a/microsoft/skills/review/al-performance-review.md
+++ b/microsoft/skills/review/al-performance-review.md
@@ -38,11 +38,19 @@ 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 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 and hot-path costs (`SetRange`, `SetFilter`, `SetLoadFields`, `SetCurrentKey`, `FindSet`, `ReadIsolation`, `LockTable`, `ModifyAll`, `DeleteAll`, `TextBuilder`, `Dictionary`, `temporary`, `repeat`, `until`, `CalcFields`, `CalcSums`).
+- The changed procedures and triggers, weighted toward those that perform loops, Find/FindSet/FindFirst calls, CalcFields, SetAutoCalcFields, CalcSums, FlowField access, Commit calls, checkpoint helpers, record copying, RecordRef conversion, Modify/Delete calls, or cross-table navigation.
+- Tokens extracted from the diff that relate to data access and hot-path costs (`SetRange`, `SetFilter`, `SetLoadFields`, `SetCurrentKey`, `FindSet`, `ReadIsolation`, `LockTable`, `ModifyAll`, `DeleteAll`, `Modify`, `Delete`, `Commit`, `checkpoint`, `Copy`, `RecordRef`, `GetTable`, `TextBuilder`, `Dictionary`, `temporary`, `repeat`, `until`, `CalcFields`, `SetAutoCalcFields`, `CalcSums`).
A file enters the candidate worklist when its `keywords` intersect the extracted tokens or its topic (derived from the index entry's `path`, `title`, and `description`) matches a changed object type. Read an article's full file β its `## Best Practice` / `## Anti Pattern` bodies β only after it makes the worklist; candidate selection uses the index alone.
+Apply these targeted cues even when simple token overlap would rank the article below the worklist cutoff:
+
+- Worklist `use-setautocalcfields-for-per-row-flowfields.md` when a record loop calls `CalcFields`, or when every row reads the same FlowField for a comparison, branch, or per-record action. Worklist `calcsums-instead-of-calcfields-in-loop.md` instead when the loop only accumulates one set total.
+- Worklist `avoid-commit-inside-loops.md` only when `Commit()` is inside a record-iteration body or a helper invoked once per row. Do not match one `Commit()` after a bounded checkpoint helper returns, a `Commit()` outside iteration, or comments and documentation that merely mention commits.
+- Worklist `avoid-cloning-records-before-modify-delete-in-loops.md` when an iteration calls `Copy` or `RecordRef.GetTable` before `Modify`/`Delete`, or passes the iterated record without `var` to a helper that writes that record. Do not worklist it from `Modify`, `Delete`, or `RecordRef` alone; exclude a direct write on the iterator, a read-only copy, a temporary record, a different target table, and a `RecordRef` opened and iterated directly.
+
+These targeted inclusions and exclusions override generic token overlap. Do not retain an excluded article solely because the diff contains one of its keywords.
+
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 performance knowledge exists, or because configuration suppressed every candidate, emit `outcome: "no-knowledge"`. When the worklist is empty because no applicable performance knowledge matched the changes, emit `outcome: "completed"` with an empty `findings` array.
@@ -89,30 +97,30 @@ Output conforms to the DO output contract. Every finding this skill emits MUST s
},
"findings": [
{
- "id": "microsoft/knowledge/performance/filter-before-find.md",
+ "id": "microsoft/knowledge/performance/apply-filters-before-iterating.md",
"severity": "major",
- "message": "FindSet is called on a record variable without any prior SetRange/SetFilter. This forces a full-table scan.",
+ "message": "The Country/Region Code predicate is evaluated inside the loop instead of with SetRange before FindSet, so every row crosses the database boundary.",
"location": {
"file": "src/Sales/PostingRoutines.Codeunit.al",
"line": 140,
"range": { "start-line": 140, "end-line": 144 }
},
"references": [
- { "path": "microsoft/knowledge/performance/filter-before-find.md" }
+ { "path": "microsoft/knowledge/performance/apply-filters-before-iterating.md" }
],
"confidence": "high",
"domain": "Performance"
},
{
- "id": "community/knowledge/performance/call-setloadfields-before-filters.md",
+ "id": "microsoft/knowledge/performance/use-setloadfields-for-partial-records.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.",
+ "message": "The loop reads only a small subset of fields from a wide table without SetLoadFields, transferring every column for each row.",
"location": {
"file": "src/Sales/PostingRoutines.Codeunit.al",
"line": 152
},
"references": [
- { "path": "community/knowledge/performance/call-setloadfields-before-filters.md" }
+ { "path": "microsoft/knowledge/performance/use-setloadfields-for-partial-records.md" }
],
"confidence": "high",
"domain": "Performance"
@@ -136,4 +144,3 @@ The empty-corpus case β BCQuality's state until performance knowledge files la
"suppressed": []
}
```
-
diff --git a/microsoft/skills/review/al-privacy-review.md b/microsoft/skills/review/al-privacy-review.md
index d14a46f..0988622 100644
--- a/microsoft/skills/review/al-privacy-review.md
+++ b/microsoft/skills/review/al-privacy-review.md
@@ -38,10 +38,12 @@ 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. 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`).
+- The changed procedures and triggers, weighted toward those that call `Error`, construct `ErrorInfo`, call `Session.LogMessage`, `StrSubstNo`, `GetLastErrorText`/`GetLastErrorCallStack`, `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`, `ErrorInfo`, `GetLastErrorText`, `GetLastErrorCallStack`, `TelemetryScope`, `FeatureTelemetry`, `CustomDimensions`, `LogUsage`, `LogUptake`, `LogError`, `ErrorText`, `ErrorCallStack`, `alErrorText`, `alErrorCallStack`, `HybridSL`, `HybridGP`, `HybridBC`).
+- Treat `ErrorInfo.Message`, `ErrorInfo.DataClassification`, `ErrorInfo.ErrorType`, and `ErrorInfo.DetailedMessage` as qualified member signals: accept a call or assignment only when symbol resolution proves that its receiver expression or variable has type `ErrorInfo`. Normalize those accesses to `errorinfo-message`, `errorinfo-dataclassification`, `errorinfo-errortype`, and `errorinfo-detailedmessage` retrieval tokens. Bare `Message` or `DataClassification` tokens MUST NOT trigger this article; do not emit the qualified tokens for `Message(...)` dialog calls, table or table-field `DataClassification` properties, or similarly named members on other types. Resolve the receiver's declaration from the containing object when it is outside the changed hunk.
+- Worklist ErrorInfo privacy guidance only from those typed `ErrorInfo` member tokens or from construction of an `ErrorInfo` value. For every `FeatureTelemetry.LogError`, inspect the dedicated error text and call-stack arguments in addition to explicit custom dimensions.
-A file enters the candidate worklist when its `keywords` intersect the extracted tokens or its topic (derived from the index entry's `path`, `title`, and `description`) matches a changed object type. Read an article's full file β its `## Best Practice` / `## Anti Pattern` bodies β only after it makes the worklist; candidate selection uses the index alone.
+A file enters the candidate worklist when its `keywords` intersect the extracted tokens or its topic (derived from the index entry's `path`, `title`, and `description`) matches a changed object type. Apply the topic-specific gates above after this overlap check; in particular, bare `Message` and `DataClassification` tokens cannot admit ErrorInfo guidance. Read an article's full file β its `## Best Practice` / `## Anti Pattern` bodies β only after it makes the worklist; candidate selection uses the index alone.
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`.
@@ -89,16 +91,16 @@ Output conforms to the DO output contract. Every finding this skill emits MUST s
},
"findings": [
{
- "id": "microsoft/knowledge/privacy/strsubstno-prebuild-breaks-error-telemetry-classification.md",
+ "id": "microsoft/knowledge/privacy/data-classification-required-on-pii-fields.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.",
+ "message": "The new Customer E-Mail table field has no DataClassification property, leaving personal data unclassified.",
"location": {
- "file": "src/Sales/CustomerValidation.Codeunit.al",
+ "file": "src/Sales/Customer.TableExt.al",
"line": 64,
"range": { "start-line": 60, "end-line": 64 }
},
"references": [
- { "path": "microsoft/knowledge/privacy/strsubstno-prebuild-breaks-error-telemetry-classification.md" }
+ { "path": "microsoft/knowledge/privacy/data-classification-required-on-pii-fields.md" }
],
"confidence": "high",
"domain": "Privacy"
@@ -107,4 +109,3 @@ Output conforms to the DO output contract. Every finding this skill emits MUST s
"suppressed": []
}
```
-
diff --git a/microsoft/skills/review/al-security-review.md b/microsoft/skills/review/al-security-review.md
index aa9d0e5..23a93cf 100644
--- a/microsoft/skills/review/al-security-review.md
+++ b/microsoft/skills/review/al-security-review.md
@@ -89,7 +89,7 @@ Output conforms to the DO output contract. Every finding this skill emits MUST s
},
"findings": [
{
- "id": "microsoft/knowledge/security/use-secrettext-for-credentials.md",
+ "id": "microsoft/knowledge/security/secrettext-for-credentials.md",
"severity": "blocker",
"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": {
@@ -98,21 +98,21 @@ Output conforms to the DO output contract. Every finding this skill emits MUST s
"range": { "start-line": 85, "end-line": 89 }
},
"references": [
- { "path": "microsoft/knowledge/security/use-secrettext-for-credentials.md" }
+ { "path": "microsoft/knowledge/security/secrettext-for-credentials.md" }
],
"confidence": "high",
"domain": "Security"
},
{
- "id": "microsoft/knowledge/security/never-hardcode-secrets-in-al.md",
+ "id": "microsoft/knowledge/security/secrets-isolated-storage.md",
"severity": "minor",
- "message": "An API key is assigned from a string literal rather than retrieved from IsolatedStorage or Key Vault at runtime.",
+ "message": "A setup table stores an API key in an ordinary Text field, exposing it through table reads and exports. Persist it in IsolatedStorage instead.",
"location": {
- "file": "src/Integration/ApiClient.Codeunit.al",
- "line": 201
+ "file": "src/Integration/ExternalServiceSetup.Table.al",
+ "line": 12
},
"references": [
- { "path": "microsoft/knowledge/security/never-hardcode-secrets-in-al.md" }
+ { "path": "microsoft/knowledge/security/secrets-isolated-storage.md" }
],
"confidence": "medium",
"domain": "Security"
@@ -136,4 +136,3 @@ The empty-corpus case β BCQuality's state until security knowledge files land
"suppressed": []
}
```
-
diff --git a/microsoft/skills/review/al-style-review.md b/microsoft/skills/review/al-style-review.md
index 5884d4e..37b9a15 100644
--- a/microsoft/skills/review/al-style-review.md
+++ b/microsoft/skills/review/al-style-review.md
@@ -53,13 +53,15 @@ When the post-conflict worklist is empty because no applicable style knowledge e
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`.
+Severity calibration β a formal analyzer already flags the mechanical presence/naming conventions (the `this` keyword AA0248, approved label suffixes AA0074, a missing `ToolTip`, required parentheses). On those, BCQuality's value is the *explanation* of why the rule exists, not a second gate; emit them at `info` so a consumer that gates on severity does not re-flag what CodeCop/AppSourceCop already reports. Reserve `minor` for style issues with concrete downstream impact the analyzer does not catch β a `Label` declared at procedure-local instead of object scope (no analyzer enforces label scope, and mis-scoped Labels are fragile in the translation pipeline), lost translation or telemetry classification from a string-built `Error`, an `OptionCaption` that does not match its `OptionMembers`, a misleading named invocation. This keeps the domain's default output advisory and prevents analyzer-redundant noise from competing with substantive review.
+
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.
-After evaluating each worklist entry, also consider whether the diff exhibits a style defect the agent recognises from its general AL knowledge that no knowledge file in the worklist covers. Such candidates are agent findings within this skill's domain β emit them with `references: []`, an `id` slug prefixed with `agent:`, `confidence` capped at `medium`, `severity` capped at `minor` (agent findings are advisory and non-gating), and a `message` that is self-contained (describing both the issue and a concrete recommendation, since there is no knowledge-file footer for the consumer to fall back on). Hold every candidate to the precision bar in `skills/do.md` (*Agent findings*): emit only a clear, widely-accepted AL style violation with a concrete basis a knowledgeable BC reviewer would agree on β steelman it first and drop personal preference, speculation, and any single defensible formatting choice among several; when in doubt, omit. The scope is strictly style; defects outside this domain belong to other leaves and MUST NOT be emitted here. Before emitting, check the worklist for a knowledge file that matches the candidate β if one exists, upgrade the candidate to a knowledge-backed finding instead. See `skills/do.md` for the full contract.
+After evaluating each worklist entry, also consider whether the diff exhibits a style defect the agent recognises from its general AL knowledge that no knowledge file in the worklist covers. Such candidates are agent findings within this skill's domain β emit them with `references: []`, an `id` slug prefixed with `agent:`, `confidence` capped at `medium`, `severity` capped at `minor` (agent findings are advisory and non-gating), and a `message` that is self-contained (describing both the issue and a concrete recommendation, since there is no knowledge-file footer for the consumer to fall back on). Hold every candidate to the precision bar in `skills/do.md` (*Agent findings*): emit only a clear, widely-accepted AL style violation with a concrete basis a knowledgeable BC reviewer would agree on β steelman it first and drop personal preference, speculation, and any single defensible formatting choice among several; when in doubt, omit. The scope is strictly style β naming, labelling, formatting, and analyzer-adjacent conventions. A correctness, logic, data-integrity, or contract defect is NOT a style finding even when it can be reworded as a convention: a method that mutates a shared `Record`'s filters, an unfiltered `DeleteAll`, a violated interface contract, or a wrong boolean guard are behavioural defects, not conventions β do not emit them here under a style framing. If a specific domain leaf covers the concern (performance, security, error-handling, β¦) it belongs there; if no knowledge file in any domain covers it, it belongs to the `al-code-review` super-skill's cross-cutting self-review agent channel (`from-sub-skill: "agent"`, `severity` capped at `minor`), not to this leaf. A reliable test: if you cannot cite a style `## Best Practice`/`## Anti Pattern` for the concern, it is very likely not a style finding. Before emitting, check the worklist for a knowledge file that matches the candidate β if one exists, upgrade the candidate to a knowledge-backed finding instead. See `skills/do.md` for the full contract.
For every emitted finding, decide whether the fix is mechanical. A fix is mechanical when it is small, local, and unambiguous from the diff context (for example: delete unreachable lines; replace `Count() > 0` with `not IsEmpty()`; move a local `Label` to object scope; add a missing `ToolTip`, `OptionCaption`, or `DataClassification`; replace a string-concatenated `Error` with a Label-backed call; change an over-broad permission token; or add an obvious `else`/guard branch). For mechanical findings, emit `findings[].suggested-code` with the literal replacement for the source lines indicated by `location`. The payload must be a verbatim replacement β no diff markers, no fences, no commentary β that the consumer can render as a one-click suggestion. When a `.good.al` companion exists and the diff context matches the `.bad.al` shape, adapt the `.good.al` replacement into `suggested-code`.
@@ -82,20 +84,20 @@ Output conforms to the DO output contract. Every finding this skill emits MUST s
"skill": { "id": "al-style-review", "version": 1 },
"outcome": "completed",
"summary": {
- "counts": { "blocker": 0, "major": 0, "minor": 1, "info": 0 },
+ "counts": { "blocker": 0, "major": 0, "minor": 0, "info": 1 },
"coverage": { "worklist-size": 1, "items-evaluated": 1 }
},
"findings": [
{
- "id": "microsoft/knowledge/style/apply-approved-label-suffixes.md",
- "severity": "minor",
+ "id": "microsoft/knowledge/style/label-suffix-approved-list.md",
+ "severity": "info",
"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" }
+ { "path": "microsoft/knowledge/style/label-suffix-approved-list.md" }
],
"confidence": "high",
"domain": "Style"
@@ -104,4 +106,3 @@ Output conforms to the DO output contract. Every finding this skill emits MUST s
"suppressed": []
}
```
-
diff --git a/microsoft/skills/review/al-telemetry-review.md b/microsoft/skills/review/al-telemetry-review.md
new file mode 100644
index 0000000..e4169a2
--- /dev/null
+++ b/microsoft/skills/review/al-telemetry-review.md
@@ -0,0 +1,93 @@
+---
+kind: action-skill
+id: al-telemetry-review
+version: 1
+title: AL telemetry review
+description: Performs an AL telemetry review against guidance from BCQuality.
+inputs: [pr-diff, file-path]
+outputs: [findings-report]
+bc-version: [all]
+technologies: [al]
+countries: [w1]
+application-area: [all]
+---
+
+# AL telemetry review
+
+Reviews AL source changes against the `telemetry` 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). Telemetry findings are narrow by design β they apply when the diff emits, wraps, or changes custom telemetry through `Session.LogMessage`, `Session.LogError`, `FeatureTelemetry`, or related telemetry helpers. The skill returns `not-applicable` when none of those apply.
+
+## Source
+
+Read the BCQuality knowledge index once β the `knowledge-index.json` BCQuality builds at the root of the knowledge checkout (Entry's preparation step regenerates it over the live, already-filtered clone β see `skills/entry.md`). It lists every article that survived layer and allow/deny filtering and carries, per article, its `path`, `layer`, `domain`, frontmatter dimensions, `keywords`, `title`, and a one-line `description` hint β exactly the fields Relevance and Worklist consume. Take the index entries whose `domain` is `telemetry` as this skill's candidate set across every enabled Microsoft, community, and custom layer; do not open the individual article files at this step. Open an article's full body only once it enters the Worklist below, so a review reads the index plus the handful of worklisted articles instead of every file under `*/knowledge/telemetry/**`.
+
+## 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 objects and procedures β especially telemetry wrapper codeunits, feature lifecycle instrumentation, error logging, integration diagnostics, and background/session processing.
+- Calls to `Session.LogMessage`, `Session.LogError`, or `FeatureTelemetry` methods, weighted toward the event ID, verbosity, data classification, custom dimensions, and `TelemetryScope` arguments.
+- Tokens extracted from the diff that relate to telemetry (`Session.LogMessage`, `Session.LogError`, `FeatureTelemetry`, `TelemetryScope`, `ExtensionPublisher`, `All`, `Verbosity`, `DataClassification`, `CustomDimensions`, `Application Insights`, `LogUsage`, `LogError`, `LogUptake`, `Feature Uptake Status`).
+
+A file enters the candidate worklist when its `keywords` intersect the extracted tokens or its topic (derived from the index entry's `path`, `title`, and `description`) matches a changed object type. Read an article's full file β its `## Best Practice` / `## Anti Pattern` bodies β only after it makes the worklist; candidate selection uses the index alone. When the diff contains no telemetry-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 (`## 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 telemetry knowledge exists, or because configuration suppressed every candidate, emit `outcome: "no-knowledge"`. When the worklist is empty because no applicable telemetry 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; otherwise 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.
+
+Set `confidence` to:
+
+- `high` when the detection is based on an unambiguous API and `TelemetryScope` argument.
+- `medium` when determining whether a signal is customer-actionable requires heuristic interpretation or when any frontmatter dimension was `unknown`.
+- `low` when the finding is an advisory derived only from applicability.
+
+After evaluating each worklist entry, also consider whether the diff exhibits a telemetry defect the agent recognises from its general AL knowledge that no knowledge file in the worklist covers. Such candidates are agent findings within this skill's domain β emit them with `references: []`, an `id` slug prefixed with `agent:`, `confidence` capped at `medium`, `severity` capped at `minor` (agent findings are advisory and non-gating), and a `message` that is self-contained (describing both the issue and a concrete recommendation, since there is no knowledge-file footer for the consumer to fall back on). Hold every candidate to the precision bar in `skills/do.md` (*Agent findings*): emit only a concrete, material telemetry defect a knowledgeable BC reviewer would agree is wrong β steelman it first and drop anything stylistic, speculative, dependent on code outside the diff, or merely a valid alternative; when in doubt, omit. The scope is strictly telemetry; defects outside this domain belong to other leaves and MUST NOT be emitted here. Before emitting, check the worklist for a knowledge file that matches the candidate β if one exists, upgrade the candidate to a knowledge-backed finding instead. See `skills/do.md` for the full contract.
+
+For every emitted finding, decide whether the fix is mechanical. A fix is mechanical when it is small, local, and unambiguous from the diff context (for example: replace `TelemetryScope::All` with `TelemetryScope::ExtensionPublisher` for a clearly publisher-only diagnostic). For mechanical findings, emit `findings[].suggested-code` with the literal replacement for the source lines indicated by `location`. The payload must be a verbatim replacement β no diff markers, no fences, no commentary β that the consumer can render as a one-click suggestion. When a `.good.al` companion exists and the diff context matches the `.bad.al` shape, adapt the `.good.al` replacement into `suggested-code`.
+
+Omit `suggested-code` only when the appropriate fix depends on context the skill cannot determine, when multiple defensible replacements exist, or when the fix spans non-contiguous code. If a finding is mechanical-looking but you omit `suggested-code`, set `findings[].suggested-code-omission-reason` to a short explanation. See `skills/do.md` for the full contract.
+
+Outcome selection:
+
+- `completed` β the skill evaluated every worklist item.
+- `no-knowledge` β no applicable telemetry knowledge survived filtering.
+- `not-applicable` β the diff touches no telemetry emission, wrapper, or feature-instrumentation surface.
+- `partial` β a budget was hit before the worklist was exhausted.
+- `failed` β an unrecoverable error occurred.
+
+## Output
+
+Output conforms to the DO output contract. Every finding this skill emits MUST set `findings[].domain` to `"Telemetry"`. The empty-corpus case produces:
+
+```json
+{
+ "skill": { "id": "al-telemetry-review", "version": 1 },
+ "outcome": "no-knowledge",
+ "summary": {
+ "counts": { "blocker": 0, "major": 0, "minor": 0, "info": 0 },
+ "coverage": { "worklist-size": 0, "items-evaluated": 0 }
+ },
+ "findings": [],
+ "suppressed": []
+}
+```
diff --git a/microsoft/skills/review/al-testing-review.md b/microsoft/skills/review/al-testing-review.md
new file mode 100644
index 0000000..01f8f16
--- /dev/null
+++ b/microsoft/skills/review/al-testing-review.md
@@ -0,0 +1,131 @@
+---
+kind: action-skill
+id: al-testing-review
+version: 1
+title: AL testing review
+description: Performs an AL testing review against guidance from BCQuality.
+inputs: [pr-diff, file-path]
+outputs: [findings-report]
+bc-version: [all]
+technologies: [al]
+countries: [w1]
+application-area: [all]
+---
+
+# AL testing review
+
+Reviews AL source changes against the `testing` 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). Testing findings are narrow by design β they apply when the diff touches test codeunits, test runners, test methods, handlers, assertions, or fixture construction. The skill returns `not-applicable` when none of those apply.
+
+## Source
+
+Read the BCQuality knowledge index once β the `knowledge-index.json` BCQuality builds at the root of the knowledge checkout (Entry's preparation step regenerates it over the live, already-filtered clone β see `skills/entry.md`). It lists every article that survived layer and allow/deny filtering and carries, per article, its `path`, `layer`, `domain`, frontmatter dimensions, `keywords`, `title`, and a one-line `description` hint β exactly the fields Relevance and Worklist consume. Take the index entries whose `domain` is `testing` as this skill's candidate set across every enabled layer; do not open the individual article files at this step. Open an article's full body only once it enters the Worklist below, so a review reads the index plus the handful of worklisted articles instead of every file under `*/knowledge/testing/**`.
+
+## 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 = Test`, test runner codeunits with `TestIsolation`, test libraries, and codeunits that define UI handlers.
+- The changed methods and attributes, weighted toward `[Test]`, `[TransactionModel(...)]`, `[HandlerFunctions(...)]`, handler attributes, `asserterror`, `ExpectedError`, `ExpectedErrorCode`, fixture initialization, and test-library calls.
+- Tokens extracted from the diff that relate to testing (`Subtype = Test`, `TestIsolation`, `TransactionModel`, `AutoRollback`, `AutoCommit`, `Commit`, `asserterror`, `ExpectedError`, `ExpectedErrorCode`, `HandlerFunctions`, `ConfirmHandler`, `MessageHandler`, `StrMenuHandler`, `ModalPageHandler`, `Enqueue`, `Dequeue`, `AssertEmpty`, `Library Assert`, `LibraryVariableStorage`, `LibrarySales`, `LibraryPurchase`, `LibraryERM`, `LibraryInventory`, `LibraryRandom`, `Init`, `Insert`).
+
+A file enters the candidate worklist when its `keywords` intersect the extracted tokens or its topic (derived from the index entry's `path`, `title`, and `description`) matches a changed object type. Read an article's full file β its `## Best Practice` / `## Anti Pattern` bodies β only after it makes the worklist; candidate selection uses the index alone. When the diff contains no testing-related changes by any of the above signals, return `outcome: "not-applicable"` without evaluating files.
+
+The following targeted checks cover every current `testing` article. Treat each as a candidate-selection cue: when the signal appears in changed code, add the named article to the worklist and evaluate it in Action.
+
+- A method in a `Subtype = Test` codeunit adds or changes `[TransactionModel(...)]`, exercises code that calls `Commit`, defaults broadly to `AutoCommit`, or uses `AutoCommit` (or exercises a path that calls `Commit`) without a `TestIsolation`-enabled runner β `transactionmodel-attribute-governs-test-transactions`. Do not worklist this article solely because an ordinary `AutoRollback` or read-only test has no `TestIsolation` runner.
+- Test fixture code manually calls `Init`/`Insert`, invents keys or prerequisite records, or bypasses available `LibrarySales`, `LibraryPurchase`, `LibraryERM`, `LibraryInventory`, `LibraryRandom`, or equivalent library codeunits β `use-library-codeunits-for-test-fixtures`.
+- `asserterror` is added or changed without a following `Assert.ExpectedError`, `Assert.ExpectedErrorCode`, or a purpose-built assertion such as `ExpectedTestFieldError` β `asserterror-needs-expectederror-and-code`.
+- A test path raises UI, `[HandlerFunctions(...)]` does not exactly match the invoked handlers, a handler hardcodes replies instead of using enqueue/dequeue expectations, or `LibraryVariableStorage.Clear`/`AssertEmpty` is missing β `ui-handlers-in-tests`.
+
+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 testing knowledge exists, or because configuration suppressed every candidate, emit `outcome: "no-knowledge"`. When the worklist is empty because no applicable testing 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 test can pass while verifying the wrong behavior or can leave committed data that contaminates later tests; otherwise 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.
+
+Set `confidence` to:
+
+- `high` when the detection is based on an unambiguous pattern match (attribute, handler declaration, assertion sequence, or fixture call).
+- `medium` when detection relies on heuristics or when any frontmatter dimension was `unknown`.
+- `low` when the finding is an advisory derived only from applicability.
+
+After evaluating each worklist entry, also consider whether the diff exhibits a testing defect the agent recognises from its general AL knowledge that no knowledge file in the worklist covers. Such candidates are agent findings within this skill's domain β emit them with `references: []`, an `id` slug prefixed with `agent:`, `confidence` capped at `medium`, `severity` capped at `minor` (agent findings are advisory and non-gating), and a `message` that is self-contained (describing both the issue and a concrete recommendation, since there is no knowledge-file footer for the consumer to fall back on). Hold every candidate to the precision bar in `skills/do.md` (*Agent findings*): emit only a concrete, material testing defect a knowledgeable BC reviewer would agree is wrong β steelman it first and drop anything stylistic, speculative, dependent on code outside the diff, or merely a valid alternative; when in doubt, omit. The scope is strictly AL testing; defects outside this domain belong to other leaves and MUST NOT be emitted here. Before emitting, check the worklist for a knowledge file that matches the candidate β if one exists, upgrade the candidate to a knowledge-backed finding instead. See `skills/do.md` for the full contract.
+
+For every emitted finding, decide whether the fix is mechanical. A fix is mechanical when it is small, local, and unambiguous from the diff context (for example: add the matching `ExpectedError` assertion after `asserterror`; add or remove a handler name in `HandlerFunctions`; add `LibraryVariableStorage.Clear` or `AssertEmpty`; or replace hand-rolled fixture creation with an evident library call). For mechanical findings, emit `findings[].suggested-code` with the literal replacement for the source lines indicated by `location`. The payload must be a verbatim replacement β no diff markers, no fences, no commentary β that the consumer can render as a one-click suggestion. When a `.good.al` companion exists and the diff context matches the `.bad.al` shape, adapt the `.good.al` replacement into `suggested-code`.
+
+Omit `suggested-code` only when the appropriate fix depends on context the skill cannot determine, when multiple defensible replacements exist, or when the fix spans non-contiguous code. If a finding is mechanical-looking but you omit `suggested-code`, set `findings[].suggested-code-omission-reason` to a short explanation. See `skills/do.md` for the full contract.
+
+Outcome selection:
+
+- `completed` β the skill evaluated every worklist item.
+- `no-knowledge` β no applicable testing knowledge survived filtering.
+- `not-applicable` β the diff touches no test codeunit, runner, method, handler, assertion, or fixture surface.
+- `partial` β a budget was hit before the worklist was exhausted.
+- `failed` β an unrecoverable error occurred.
+
+## Output
+
+Output conforms to the DO output contract. Every finding this skill emits MUST set `findings[].domain` to `"Testing"`. A populated example:
+
+```json
+{
+ "skill": { "id": "al-testing-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/testing/asserterror-needs-expectederror-and-code.md",
+ "severity": "major",
+ "message": "The negative test uses asserterror without checking the resulting message or error code, so any unrelated setup or permission error can make the test pass.",
+ "location": {
+ "file": "test/SalesPostingTests.Codeunit.al",
+ "line": 42
+ },
+ "references": [
+ { "path": "microsoft/knowledge/testing/asserterror-needs-expectederror-and-code.md" }
+ ],
+ "confidence": "high",
+ "domain": "Testing",
+ "suggested-code": "asserterror PostInvalidOrder();\nAssert.ExpectedError(ExpectedPostingErr);"
+ }
+ ],
+ "suppressed": []
+}
+```
+
+The empty-corpus case produces:
+
+```json
+{
+ "skill": { "id": "al-testing-review", "version": 1 },
+ "outcome": "no-knowledge",
+ "summary": {
+ "counts": { "blocker": 0, "major": 0, "minor": 0, "info": 0 },
+ "coverage": { "worklist-size": 0, "items-evaluated": 0 }
+ },
+ "findings": [],
+ "suppressed": []
+}
+```
diff --git a/microsoft/skills/review/al-ui-review.md b/microsoft/skills/review/al-ui-review.md
index 3f49ea0..19f03c9 100644
--- a/microsoft/skills/review/al-ui-review.md
+++ b/microsoft/skills/review/al-ui-review.md
@@ -16,7 +16,7 @@ application-area: [all]
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.
+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 implement Business Central control add-ins, including their client-service communication. The skill returns `not-applicable` when the diff contains no page or control add-in 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.
@@ -39,9 +39,9 @@ Discard files that are not applicable. Retain conditionally applicable files onl
Narrow the relevant files to the subset that applies to the changes under review.
-- **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).
+- **UI-file filter.** UI review applies to files declaring `page`, `pageextension`, or `pagecustomization`, and to JavaScript/CSS/HTML that implements a control add-in's rendering or Business Central communication. 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 files, weighted toward `Caption`, `ToolTip`, `AboutTitle`, `AboutText`, `OptionCaption`, `ShowCaption`, `InstructionalText`, `GridLayout`, `Style`, `StyleExpr`, promoted action definitions, field importance, page background tasks, DOM creation, ARIA attributes, keyboard/focus handlers, packaged-resource AJAX, and calls from JavaScript into AL.
+- Tokens extracted from the diff (`Caption`, `ToolTip`, `AboutTitle`, `AboutText`, `PageType`, `ShowCaption`, `InstructionalText`, `grid`, `fixed`, `GridLayout`, `Style`, `StyleExpr`, `Importance`, `Promoted`, `Additional`, `area(Promoted)`, `actionref`, `PromotedCategory`, `PromotedOnly`, `PromotedIsBig`, `ShowAs`, `SplitButton`, `EnqueueBackgroundTask`, `OnAfterGetCurrRecord`, `OnAfterGetRecord`, `OnPageBackgroundTaskCompleted`, `OnPageBackgroundTaskError`, `RunPageBackgroundTask`, `Favorable`, `Unfavorable`, `Ambiguous`, `cuegroup`, `controladdin`, `control-add-in`, `usercontrol`, `aria-`, `tabindex`, `keydown`, `focus`, `innerHTML`, `createElement`, `packaged-resource`, `ajax`, `$.get`, `$.ajax`, `XMLHttpRequest`, `xhrFields`, `withCredentials`, `withcredentials`, `InvokeExtensibilityMethod`, `invokeextensibilitymethod`, `skipIfBusy`, `successCallback`, `success-callback`, `errorCallback`, `setInterval`, `JSON.stringify`, `payload`, `throttling`, `reduced-functionality`, `ClientServicesMaxUploadSize`, `&`, `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 (derived from the index entry's `path`, `title`, and `description`) matches a changed page element. Read an article's full file β its `## Best Practice` / `## Anti Pattern` bodies β only after it makes the worklist; candidate selection uses the index alone.
@@ -53,6 +53,8 @@ When the post-conflict worklist is empty because no applicable UI knowledge exis
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.
+For packaged-resource requests, flag `$.get` or AJAX/XHR that omits `withCredentials` only when the URL is identifiable as a resource in the control add-in package; do not generalize the rule to external endpoints. For `InvokeExtensibilityMethod`, flag repeated or timer-driven calls that can overlap because they do not wait for the success/error callbacks, and unbounded serialized payloads sent in one call. Prefer bounded chunks serialized through completion callbacks. Do not emit generic browser or JavaScript performance advice.
+
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).
@@ -69,7 +71,7 @@ Outcome selection:
- `completed` β the skill evaluated every worklist item.
- `no-knowledge` β no applicable UI knowledge survived filtering.
-- `not-applicable` β the diff contains no page, pageextension, pagecustomization, or control add-in UI files.
+- `not-applicable` β the diff contains no page, pageextension, pagecustomization, or control add-in implementation files.
- `partial` β a budget was hit before the worklist was exhausted.
- `failed` β an unrecoverable error occurred.
@@ -87,15 +89,15 @@ Output conforms to the DO output contract. Every finding this skill emits MUST s
},
"findings": [
{
- "id": "microsoft/knowledge/ui/field-tooltips-start-with-specifies-and-end-with-period.md",
+ "id": "microsoft/knowledge/ui/show-caption-on-editable-fields.md",
"severity": "minor",
- "message": "Field ToolTip is a fragment ('Customer name') β missing the 'Specifies' opener and the terminating period the house-style guidance requires.",
+ "message": "An editable page field sets ShowCaption = false, removing the visible and accessible label. Leave ShowCaption enabled or use a documented exception pattern.",
"location": {
"file": "src/Sales/CustomerCard.Page.al",
"line": 58
},
"references": [
- { "path": "microsoft/knowledge/ui/field-tooltips-start-with-specifies-and-end-with-period.md" }
+ { "path": "microsoft/knowledge/ui/show-caption-on-editable-fields.md" }
],
"confidence": "high",
"domain": "Accessibility"
@@ -104,4 +106,3 @@ Output conforms to the DO output contract. Every finding this skill emits MUST s
"suppressed": []
}
```
-
diff --git a/microsoft/skills/review/al-upgrade-review.md b/microsoft/skills/review/al-upgrade-review.md
index f37dad9..2cecf0c 100644
--- a/microsoft/skills/review/al-upgrade-review.md
+++ b/microsoft/skills/review/al-upgrade-review.md
@@ -38,8 +38,11 @@ Discard files that are not applicable. Retain conditionally applicable files (an
Narrow the relevant files to the subset that applies to the changes under review. For each relevant file, compute overlap against:
- The changed AL object names and types β especially codeunits with `Subtype = Upgrade` or `Subtype = Install`, tables and tableextensions adding or changing fields, enums and enumextensions, and objects under `Hybrid*`/`Migration`/`Upgrade` namespaces.
-- The changed triggers and procedures, weighted toward `OnUpgradePerCompany`, `OnUpgradePerDatabase`, `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`).
+- The changed triggers and procedures, weighted toward `OnCheckPreconditionsPerCompany`/`PerDatabase`, `OnUpgradePerCompany`/`PerDatabase`, `OnValidateUpgradePerCompany`/`PerDatabase`, `OnInstallAppPerCompany`/`PerDatabase`, the `OnGetPerCompanyUpgradeTags`/`OnGetPerDatabaseUpgradeTags` subscribers, and helper procedures transitively reachable from those entry points.
+- Tokens extracted from the diff that relate to upgrade concerns (`Subtype = Upgrade`, `Subtype = Install`, `Upgrade Tag`, `HasUpgradeTag`, `SetUpgradeTag`, `OnCheckPreconditions`, `OnUpgrade`, `OnValidateUpgrade`, `OnInstallApp`, `DataTransfer`, `CopyFields`, `Insert`, `Modify`, `Delete`, `Rename`, `InitValue`, `ObsoleteState`, `ObsoleteReason`, `ObsoleteTag`, `DataVersion`, `ExecutionContext`, `PrimaryKey`, `key(`, `field(`, `value(`, `enum`, `enumextension`, `HybridSL`, `HybridGP`, `HybridBC`, `HybridBaseDeployment`).
+- For each `OnCheckPreconditions...` and `OnValidateUpgrade...` trigger, build the best available call graph from surrounding unchanged source as well as changed hunks, tracing resolved calls through reachable local or internal helpers. Worklist the check-only rule when a database write occurs either directly in the trigger or in any helper procedure reachable from it. Writes include `Insert`, `Modify`, `ModifyAll`, `Delete`, `DeleteAll`, `Rename`, and `DataTransfer`. Also perform the reverse check when a PR changes a writing helper body: worklist the rule when that helper is invoked directly or transitively by an unchanged check or validation trigger.
+- Treat a direct write or a fully resolved call chain as high-confidence evidence. When cross-object dispatch, unavailable declarations, or an incomplete call graph prevents proving the complete chain, cap confidence at `medium`, name the unresolved edge in the finding, and do not claim a violation without a resolved path from a check or validation trigger to a write.
+- Worklist the install-versus-upgrade rule when migration helpers are reachable only from an install codeunit.
A file enters the candidate worklist when its `keywords` intersect the extracted tokens or its topic (derived from the index entry's `path`, `title`, and `description`) matches a changed object type. Read an article's full file β its `## Best Practice` / `## Anti Pattern` bodies β only after it makes the worklist; candidate selection uses the index alone. When the diff contains no upgrade-related changes by any of the above signals, return `outcome: "not-applicable"` without evaluating files.
@@ -57,7 +60,7 @@ For each worklist entry, evaluate the diff against the file's `## Best Practice`
Set `confidence` to:
-- `high` when the detection is based on an unambiguous pattern match.
+- `high` when the detection is based on an unambiguous pattern match and any required helper reachability is fully established.
- `medium` when detection relies on heuristics or when any frontmatter dimension was `unknown`.
- `low` when the finding is an advisory derived only from applicability.
@@ -89,7 +92,7 @@ Output conforms to the DO output contract. Every finding this skill emits MUST s
},
"findings": [
{
- "id": "microsoft/knowledge/upgrade/enum-changes-must-be-additive-at-the-end.md",
+ "id": "microsoft/knowledge/upgrade/enum-values-additive-at-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": {
@@ -97,7 +100,7 @@ Output conforms to the DO output contract. Every finding this skill emits MUST s
"line": 7
},
"references": [
- { "path": "microsoft/knowledge/upgrade/enum-changes-must-be-additive-at-the-end.md" }
+ { "path": "microsoft/knowledge/upgrade/enum-values-additive-at-end.md" }
],
"confidence": "high",
"domain": "Upgrade"
@@ -106,4 +109,3 @@ Output conforms to the DO output contract. Every finding this skill emits MUST s
"suppressed": []
}
```
-
diff --git a/microsoft/skills/review/al-web-services-review.md b/microsoft/skills/review/al-web-services-review.md
index b3835a1..551dce5 100644
--- a/microsoft/skills/review/al-web-services-review.md
+++ b/microsoft/skills/review/al-web-services-review.md
@@ -3,11 +3,11 @@ kind: action-skill
id: al-web-services-review
version: 1
title: AL web services review
-description: Reviews AL source changes against web-services (API page) guidance from BCQuality.
+description: Reviews AL API surfaces and webhook integration handlers against web-services guidance from BCQuality.
inputs: [pr-diff, file-path]
outputs: [findings-report]
bc-version: [all]
-technologies: [al]
+technologies: [al, javascript]
countries: [w1]
application-area: [all]
---
@@ -27,7 +27,7 @@ Read the BCQuality knowledge index once β the `knowledge-index.json` BCQuality
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]`.
+- `technologies` β `[al]` or `[javascript]`.
- `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`.
@@ -37,9 +37,10 @@ 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 page objects declared with `PageType = API`, and any procedure on such a page that exposes a bound action.
-- The changed properties and triggers, weighted toward API page metadata (`APIPublisher`, `APIGroup`, `APIVersion`, `EntityName`, `EntitySetName`, `ODataKeyFields`, `SourceTable`), CRUD guards (`InsertAllowed`, `ModifyAllowed`, `DeleteAllowed`, `Editable`), the `OnOpenPage` trigger, and `OnValidate` triggers on exposed fields.
-- Tokens extracted from the diff that relate to API surface and behaviour (`PageType`, `API`, `APIPublisher`, `APIGroup`, `APIVersion`, `EntityName`, `EntitySetName`, `ODataKeyFields`, `SystemId`, `ServiceEnabled`, `WebServiceActionContext`, `SetActionResponse`, `ReadIsolation`, `IsolationLevel`, `ReadCommitted`, `InsertAllowed`, `ModifyAllowed`, `DeleteAllowed`, `Editable`, `SourceTable`).
+- The changed AL object names and types β especially pages declared with `PageType = API`, API page `part` controls, queries declared with `QueryType = API`, and procedures that expose bound actions.
+- The changed properties and triggers, weighted toward API page metadata (`APIPublisher`, `APIGroup`, `APIVersion`, `EntityName`, `EntitySetName`, `ODataKeyFields`, `SourceTable`, `SourceTableTemporary`), navigation metadata (`SubPageLink`, `Multiplicity`, and visible singleton or collection semantics), CRUD guards (`InsertAllowed`, `ModifyAllowed`, `DeleteAllowed`, `Editable`), the `OnOpenPage` trigger, and `OnValidate` triggers on exposed fields.
+- Webhook subscriber handlers and subscription lifecycle code, especially code that creates or renews subscriptions, handles `validationToken`, schedules from `expirationDateTime`, or targets resources whose eligibility is visible in the diff.
+- Tokens extracted from the diff that relate to API surface and behaviour (`PageType`, `QueryType`, `API`, `api-page`, `page-part`, `APIPublisher`, `APIGroup`, `APIVersion`, `EntityName`, `EntitySetName`, `ODataKeyFields`, `SystemId`, `SubPageLink`, `subpagelink`, `Multiplicity`, `multiplicity`, `Many`, `ZeroOrOne`, `SourceTableTemporary`, `Job Queue Entry`, `webhook`, `webhookSupportedResources`, `webhook-supported-resources`, `subscriptions`, `notificationUrl`, `validationToken`, `validationtoken`, `expirationDateTime`, `expirationdatetime`, `ServiceEnabled`, `WebServiceActionContext`, `SetActionResponse`, `ReadIsolation`, `IsolationLevel`, `ReadCommitted`, `InsertAllowed`, `ModifyAllowed`, `DeleteAllowed`, `Editable`, `SourceTable`).
A file enters the candidate worklist when its `keywords` intersect the extracted tokens or its topic (derived from the index entry's `path`, `title`, and `description`) matches a changed object type. Read an article's full file β its `## Best Practice` / `## Anti Pattern` bodies β only after it makes the worklist; candidate selection uses the index alone.
@@ -55,6 +56,8 @@ For each worklist entry, evaluate the diff against the file's `## Best Practice`
- 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`.
+For API parts whose parent declares `ODataKeyFields = SystemId`, detect a child foreign key linked to a parent business field instead of `Field(SystemId)`. Do not apply the SystemId-link rule to APIs intentionally keyed by another field. Omitted `Multiplicity` is valid and means the documented default 1:N collection; never report omission alone. Report an explicit `ZeroOrOne` only when the visible contract clearly intends a collection or deep insert, and report an explicit `Many` only when it clearly intends a singleton. Singleton metadata requires an explicit `ZeroOrOne`; do not infer singleton intent from naming alone. For webhook eligibility, detect `QueryType = API`, `SourceTableTemporary = true`, composite `ODataKeyFields` (including an omitted property when a visible source primary key is composite), Job Queue Entry, and visible system-table sources; do not infer an unknown table number. For lifecycle code, require both create and renew paths to use a handler that returns the query-string `validationToken` verbatim with `200 OK`, and flag renewal scheduling that assumes subscriptions are permanent instead of using `expirationDateTime`. Do not emit generic HTTP or REST advice.
+
Set `confidence` to:
- `high` when the detection is based on an unambiguous pattern match (identifier, syntax, object type).
@@ -71,7 +74,7 @@ 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 web-services 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).
+- `not-applicable` β the task context contains no AL API surface, JavaScript webhook subscription lifecycle code, or JavaScript notification handler, or the `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.
@@ -84,14 +87,14 @@ Output conforms to the DO output contract. Every finding this skill emits MUST s
"skill": { "id": "al-web-services-review", "version": 1 },
"outcome": "completed",
"summary": {
- "counts": { "blocker": 0, "major": 1, "minor": 1, "info": 0 },
+ "counts": { "blocker": 0, "major": 0, "minor": 2, "info": 0 },
"coverage": { "worklist-size": 2, "items-evaluated": 2 }
},
"findings": [
{
"id": "microsoft/knowledge/web-services/set-required-api-page-properties.md",
- "severity": "major",
- "message": "This PageType = API page declares a SourceTable but omits APIPublisher and APIGroup, so the endpoint route cannot be composed and the entity is never published. Declare all six required API page properties.",
+ "severity": "minor",
+ "message": "This PageType = API page omits APIVersion, so it is exposed under beta by default rather than an explicit stable contract. Declare the intended version, such as APIVersion = 'v1.0'.",
"location": {
"file": "src/Api/CustomerApi.Page.al",
"line": 3,
diff --git a/skills/bcquality-al-review/SKILL.md b/skills/bcquality-al-review/SKILL.md
new file mode 100644
index 0000000..a11f891
--- /dev/null
+++ b/skills/bcquality-al-review/SKILL.md
@@ -0,0 +1,91 @@
+---
+name: bcquality-al-review
+description: Review Business Central AL code changes using the BCQuality knowledge base. Use when reviewing an AL pull request, a working-tree diff, or a single AL file, and you want findings backed by BCQuality's curated, BC-specific quality rules.
+---
+
+# BCQuality AL review
+
+This skill drives the BCQuality **Entry protocol** over the knowledge base that ships
+inside this plugin. It is the plugin entry point for consumers (orchestrators, CLIs)
+that do not already know BCQuality's internal conventions β the only convention they
+need is "invoke this skill for an AL review."
+
+BCQuality itself is orchestrator-agnostic content: knowledge files plus routing and
+action skills. This bridge is the thin consumer glue that lets a plugin host run that
+content without hardcoding BCQuality's layout.
+
+## When to use
+
+- Reviewing an AL pull request or an uncommitted working-tree diff.
+- Reviewing a single AL file.
+- Any task whose goal is "review Business Central / AL code for quality issues."
+
+Do **not** use this skill to *generate* AL code β it only reviews.
+
+## Plugin root
+
+Resolve `PLUGIN_ROOT` to the directory that contains this plugin's
+`.claude-plugin/plugin.json`. This skill lives at
+`PLUGIN_ROOT/skills/bcquality-al-review/SKILL.md`, so `PLUGIN_ROOT` is two levels up
+from this file. All paths below are relative to `PLUGIN_ROOT`. If the host exposes a
+plugin-root environment variable, prefer it.
+
+## Steps
+
+1. **Refresh the knowledge index (best effort).** If `pwsh` is available, run
+ `pwsh PLUGIN_ROOT/tools/Build-KnowledgeIndex.ps1` from `PLUGIN_ROOT` to (re)generate
+ `PLUGIN_ROOT/knowledge-index.json` over the installed tree. This is a discovery
+ accelerator only β if `pwsh` is missing or the build fails, continue; the review
+ skills fall back to path-based discovery.
+
+2. **Run Entry.** Read `PLUGIN_ROOT/skills/entry.md` and execute it against a
+ task context describing the review:
+
+ ```yaml
+ task-context:
+ goal: "Review the AL changes for quality issues"
+ inputs-available: [pr-diff] # or [file-path] for single-file review
+ technologies: [al]
+ enabled-layers: [microsoft, community, custom] # see "Layer selection" below
+ ```
+
+ **Layer selection.** `enabled-layers` defaults to all three layers. A host can
+ narrow it by setting the `BCQUALITY_ENABLED_LAYERS` environment variable to a
+ comma-separated subset (e.g. `microsoft` or `microsoft,community`); when set, pass
+ exactly those layers instead of the default. This is the plugin path's only knob
+ for layer policy β see the limitation in Notes.
+
+ Fill `bc-version`, `countries`, and `application-area` only when the caller
+ supplies them; omit them otherwise (an omitted dimension is unconstrained).
+
+3. **Follow the dispatch record.** Entry returns a dispatch record naming the action
+ skill(s) to invoke β for a PR review this is normally
+ `microsoft/skills/review/al-code-review.md`. For each dispatched skill, read the
+ file and execute its Source β Relevance β Worklist β Action steps, reading
+ `PLUGIN_ROOT/skills/read.md` and `PLUGIN_ROOT/skills/do.md` on demand.
+
+4. **Emit findings.** Produce the rolled-up findings report in the DO output contract,
+ including each review finding's producer-supplied `domain` label (`outcome`,
+ `findings`, `references`, `confidence`, `suppressed`). Do not invent a different
+ shape; downstream consumers parse the DO contract without skill-specific logic.
+
+If Entry returns `no-match` or `failed`, return the dispatch record unchanged so the
+caller can log the reason.
+
+## Notes
+
+- This skill adds nothing to BCQuality's knowledge or routing logic; it only bootstraps
+ the existing Entry protocol from a plugin host. Knowledge and skill changes belong in
+ the layers under `PLUGIN_ROOT/microsoft/`, `PLUGIN_ROOT/community/`, and
+ `PLUGIN_ROOT/custom/`, not here.
+- **Layer pruning is coarser than the URL/clone model.** In the clone model a consumer
+ prunes its checkout to policy *before* the agent runs, and the knowledge index is
+ rebuilt over the pruned tree, so a denied layer can never leak into discovery. A
+ plugin install ships the whole tree, so this bridge can only *narrow discovery* via
+ `enabled-layers` (`BCQUALITY_ENABLED_LAYERS`) β the denied layers' files still exist on
+ disk. Treat `enabled-layers` as a selection filter, not a hard security boundary. A
+ future revision could add a genuine deny mechanism (e.g. pruning the installed tree).
+- **Manifest location.** This plugin uses `.claude-plugin/plugin.json`, which both
+ Claude Code and Copilot CLI accept (verified with Copilot CLI: `plugin install`
+ reports the bridge skill loaded). Copilot CLI also accepts a root `plugin.json`; if a
+ future host only reads the root form, dual-home the manifest.
diff --git a/skills/do.md b/skills/do.md
index e2ada69..38d910d 100644
--- a/skills/do.md
+++ b/skills/do.md
@@ -190,7 +190,7 @@ The first reference is the **primary** reference: the knowledge file the finding
**`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 cross-cutting reasoning. Absent on findings emitted directly by a leaf skill β including agent findings the leaf emits within its own domain, which appear in the leaf's own report without this field.
-**`findings[].domain`** β optional. A short, human-readable display label for the review domain that produced the finding (for example, `Security`, `Performance`, `Accessibility`). Set by the leaf skill on every finding it emits. The super-skill preserves it verbatim when rolling a sub-skill's finding into its top-level `findings[]`, and sets it to `"Agent"` for the agent findings it emits about cross-cutting concerns. Consumers SHOULD render it verbatim and MUST tolerate its absence (older producers may not emit it).
+**`findings[].domain`** β optional in the shared schema for backward compatibility and for non-review findings. It is a short, human-readable display label for the review domain that produced the finding (for example, `Security`, `Performance`, `Accessibility`). A review leaf skill MUST set it on every finding it emits. A review super-skill MUST preserve it verbatim when rolling a leaf finding into its top-level `findings[]`, including preserving its absence from older producers, and MUST set it to `"Agent"` for agent findings it emits about cross-cutting concerns. Consumers SHOULD render a non-empty value verbatim and MUST tolerate its absence.
**`findings[].suggested-code`** β optional in the schema but **expected for mechanical findings**. It is a concrete code-replacement payload for the lines indicated by `location`. When present, the string MUST be a literal replacement for the source lines covered by `location.line` (or `location.range` if set) β i.e., what the file would contain after the fix, with no surrounding diff markers, fences, or commentary. Consumers MAY render it as a one-click suggestion in the delivery surface (for example, a GitHub ```` ```suggestion ```` block).
@@ -229,7 +229,7 @@ The five required sections still apply. Their meaning shifts from knowledge file
- `## Source` β names the sub-skills invoked (mirrors `sub-skills` in frontmatter).
- `## Relevance` β rules for deciding which sub-skills apply to the current task. A sub-skill is relevant when its declared `inputs` are satisfied by the orchestrator's provided inputs and the orchestrator has not disabled it via configuration. The super-skill MUST NOT filter sub-skills by task content (for example, by inspecting the diff or the file). Task-level applicability is the sub-skill's own responsibility; sub-skills signal non-applicability by returning `outcome: "not-applicable"` or `outcome: "no-knowledge"`.
- `## Worklist` β the final list of sub-skills to invoke; the rest go to `skipped-sub-skills`.
-- `## Action` β invoke each worklisted sub-skill with the appropriate subset of inputs, collect its findings-report verbatim into `sub-results`, and copy its `findings[]` into the super-skill's top-level `findings[]` with `from-sub-skill` set. Findings from a sub-skill with `outcome: "failed"` MUST NOT be copied into the super-skill's top-level `findings[]` and MUST NOT contribute to the super-skill's `summary.counts` (their report is still preserved in `sub-results` for traceability, consistent with DO's rule that consumers ignore a failed skill's findings).
+- `## Action` β invoke each worklisted sub-skill with the appropriate subset of inputs, collect its findings-report verbatim into `sub-results`, and copy its `findings[]` into the super-skill's top-level `findings[]` with `from-sub-skill` set. All finding fields, including the optional `domain`, are preserved verbatim unless this contract explicitly requires a transformation. Findings from a sub-skill with `outcome: "failed"` MUST NOT be copied into the super-skill's top-level `findings[]` and MUST NOT contribute to the super-skill's `summary.counts` (their report is still preserved in `sub-results` for traceability, consistent with DO's rule that consumers ignore a failed skill's findings).
- `## Output` β the super-skill's output contract, including `sub-results` and, if any, `skipped-sub-skills`.
### Outcome rollup
@@ -292,4 +292,3 @@ Conforms to the DO output contract.
An orchestrator invokes an action skill with an input appropriate to the skill's declared `inputs`, receives the JSON output, and maps findings to its delivery surface (PR comments, build gates, IDE diagnostics). The orchestrator MUST NOT interpret skill-specific fields beyond the schema above. Skills that need richer semantics MUST encode them within the schema (for example, by adding structured `message` text) rather than extending the output shape.
-
diff --git a/skills/read.md b/skills/read.md
index 8badb97..6a2080d 100644
--- a/skills/read.md
+++ b/skills/read.md
@@ -115,7 +115,7 @@ Consumers MUST NOT silently treat missing context as a match.
## Citing a knowledge file
-A consumer that produces output referencing a knowledge file MUST cite it by its repo-relative path (for example, `microsoft/knowledge/performance/filter-before-find.md`). Line numbers are not stable references; use the file path only. If a commit SHA is available to the consumer, it SHOULD be included alongside the path.
+A consumer that produces output referencing a knowledge file MUST cite it by its repo-relative path (for example, `microsoft/knowledge/performance/apply-filters-before-iterating.md`). Line numbers are not stable references; use the file path only. If a commit SHA is available to the consumer, it SHOULD be included alongside the path.
## Sample files
diff --git a/skills/write.md b/skills/write.md
index 9a3b208..754fe1e 100644
--- a/skills/write.md
+++ b/skills/write.md
@@ -65,6 +65,17 @@ Knowledge files do not contain code. Samples live as **sibling files** next to t
- **`/community/knowledge//`** β shared community patterns. The default layer for contributions from outside the platform team. Content here can be promoted to `/microsoft/` once it proves itself.
- **`/custom/knowledge//`** β partner or customer overrides. Generally does not appear in the BCQuality repository itself; `/custom/` lives in consumer repositories.
+### Writing to `/custom/` β fork precondition
+
+The `/custom/` layer is **empty by default** in the upstream `microsoft/BCQuality` repository β it ships as a template (`README.md` plus `.gitkeep` placeholders) and is meant to be populated only inside a **fork or consumer clone** that an organization controls. Custom content is partner- or customer-specific by definition and is never accepted upstream.
+
+Before authoring or scaffolding any file under `/custom/knowledge/` or `/custom/skills/`, an author β human or agent β MUST confirm the working repository is **not** `microsoft/BCQuality`:
+
+- Check the `origin` remote: `git remote get-url origin`. If it points at `github.com/microsoft/BCQuality`, stop β you are in the upstream repo, not a fork.
+- If you are in the upstream repo, do not write the file. Either fork the repository (or clone it into your organization's own repo) and add the custom content there, or β if the guidance is genuinely shareable β author it in `/community/knowledge/` instead.
+
+A pull request that adds `/custom/` content to `microsoft/BCQuality` will be **automatically closed** by the `Guard custom layer` workflow. Validate the fork precondition first so authoring effort is not wasted on a PR that cannot be merged.
+
## Pre-PR checklist
Before opening a pull request: