Regenerate microsoft/knowledge from upstream BCApps instructions

The previous LLM-generated knowledge files contained factual
hallucinations. The most visible was the claim that `FindFirst` /
`FindLast` "forces a full-table scan" on an unfiltered record - it does
not; those APIs return a single row via the current key.

Other inaccuracies the audit found and fixed:

* `FindSet(true)` was described as "taking a LockTable". The correct
  upstream phrasing is that `FindSet(true)` sets
  `ReadIsolation::UpdLock` on the read. UpdLock and LockTable are
  related but distinct mechanisms.
* The list of production-scale tables had been invented beyond the
  upstream source (e.g. "Detailed Cust. Ledg. Entry") without a
  citation. The regenerated list matches the ten tables upstream lists
  with their P95 row counts.
* `SetLoadFields` guidance had been augmented with an extra mechanism
  claim ("the database resolves the filter using the index without
  hydrating the value") not present in upstream.

Approach: full regeneration of `microsoft/knowledge/` from the six
upstream BCApps Code Review instruction files, with Microsoft Learn /
the AL language reference as a secondary source. Every claim in every
regenerated file is anchored to a verbatim upstream quote (or a Learn
URL); the audit trail lives in artifacts/trace-<domain>.json on the
session workspace.

The PR #11 transaction/error-handling cluster is preserved verbatim:

* performance/understand-implicit-transaction-boundary.md
* performance/codeunit-run-as-atomic-sub-operation.{md,good.al,bad.al}
* performance/codeunit-run-requires-prior-commit-inside-transaction.{md,good.al,bad.al}
* performance/use-tryfunction-for-error-catching-not-rollback.{md,good.al,bad.al}
* performance/avoid-commit-inside-loops.{md,good.al,bad.al}
* security/commitbehavior-attribute-scopes-explicit-commits.{md,good.al,bad.al}
* testing/transactionmodel-attribute-governs-test-transactions.{md,good.al,bad.al}

These articles already cite Microsoft Learn and were carefully
cross-referenced; the regeneration skips their topics rather than
duplicating them.

File counts after regeneration:

  performance   35 .md  (5 preserved + 30 new)
  privacy       17 .md
  security      18 .md  (1 preserved + 17 new)
  style         33 .md
  testing        1 .md  (preserved)
  ui            19 .md
  upgrade       18 .md

Total 141 atomic knowledge files, each strictly one rule. All pass
.github/scripts/validate_frontmatter.py with 0 errors and 0 warnings.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Jesper Schulz-Wedde 2026-05-21 09:53:09 +02:00
parent 613c4b4019
commit a9f3c50863
562 changed files with 6293 additions and 4869 deletions

View file

@ -0,0 +1,11 @@
codeunit 50207 "Privacy Sample StrSubstNo Bad"
{
procedure ReportFailure(var Customer: Record Customer)
var
ErrorMsg: Text;
begin
ErrorMsg := StrSubstNo('Customer %1 (%2) at %3 has invalid data',
Customer.Name, Customer."E-Mail", Customer.Address);
Error(ErrorMsg);
end;
}

View file

@ -0,0 +1,9 @@
codeunit 50206 "Privacy Sample StrSubstNo Good"
{
procedure ReportFailure(var Customer: Record Customer)
var
CustomerInvalidErr: Label 'Customer %1 has invalid data (email: %2).', Comment = '%1 = Customer No., %2 = E-Mail';
begin
Error(CustomerInvalidErr, Customer."No.", Customer."E-Mail");
end;
}

View file

@ -0,0 +1,26 @@
---
bc-version: [all]
domain: privacy
keywords: [strsubstno, error, telemetry, pii, prebuild, text-variable]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Do not pre-build an error string with `StrSubstNo` before calling `Error()`
## Description
`StrSubstNo` returns a plain `Text` value with the substitutions already performed. When that result is then passed to `Error()`, the platform sees a single plain-text parameter with no field references left to inspect, so it cannot apply `DataClassification` to anything inside it. Whatever PII the `StrSubstNo` call interpolated — customer name, e-mail, address, error text — is logged verbatim to telemetry. This is the canonical way to accidentally leak customer data through error telemetry, and it is the only `Error()` shape that needs to be flagged.
## Best Practice
Call `Error()` directly with the format string and the substitution parameters. The platform classifies each parameter individually and handles telemetry correctly even when the parameters are PII fields (see `error-direct-substitution-safe-for-telemetry.md`). If the message text needs to be a `Label`, pass the `Label` and the parameters to `Error()` — do not pre-render via `StrSubstNo`.
See sample: `avoid-strsubstno-prebuild-before-error.good.al`.
## Anti Pattern
Assigning `StrSubstNo('Customer %1 (%2) ...', Customer.Name, Customer."E-Mail")` to a `Text` variable and then calling `Error(ErrorMsg)`. The platform has nothing to classify by the time `Error` runs — the PII is baked into the string and goes straight to telemetry. Detection signal for a reviewer: any `Text` variable assigned from `StrSubstNo` and later passed as the *only* parameter to `Error()`.
See sample: `avoid-strsubstno-prebuild-before-error.bad.al`.

View file

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

View file

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

View file

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

View file

@ -0,0 +1,22 @@
---
bc-version: [all]
domain: privacy
keywords: [data-classification, page-field, table-field, api-page, card-page, list-page]
technologies: [al]
countries: [w1]
application-area: [all]
---
# DataClassification is a table-field property, not a page-field property
## Description
`DataClassification` is defined on table fields. Pages — including `Card`, `List`, `API`, and `ListPart` — do not own a classification; they simply expose fields whose classification is inherited from the underlying table. A page-level `DataClassification` property does not exist, so neither a missing nor a "wrong" classification can be reported against a page. When the underlying table field is misclassified, the fix is on the table definition, not on every page that surfaces the field.
## Best Practice
When reviewing a page that exposes a field believed to be under-classified, follow the field back to its source table and inspect (or correct) the `DataClassification` there. A single corrected table field propagates to every page, report and API that uses it.
## Anti Pattern
Flagging a page (or trying to add a `DataClassification` property to a page field) because the page displays personal data. Pages display data that authenticated, permissioned users are already entitled to see; the classification belongs on the table field that stores the data, not on the UI that renders it.

View file

@ -0,0 +1,11 @@
tableextension 50201 "Customer Contact Ext Bad" extends Customer
{
fields
{
field(50201; "Secondary Email"; Text[80])
{
DataClassification = SystemMetadata;
Caption = 'Secondary Email';
}
}
}

View file

@ -0,0 +1,11 @@
tableextension 50200 "Customer Contact Ext" extends Customer
{
fields
{
field(50200; "Secondary Email"; Text[80])
{
DataClassification = CustomerContent;
Caption = 'Secondary Email';
}
}
}

View file

@ -0,0 +1,26 @@
---
bc-version: [all]
domain: privacy
keywords: [data-classification, pii, gdpr, customer-content, table-field, under-classified]
technologies: [al]
countries: [w1]
application-area: [all]
---
# DataClassification is required on table fields containing sensitive data
## Description
`DataClassification` is the AL property that tells the platform what kind of data a table field stores so that telemetry, GDPR data-subject requests, and the platform's audit surfaces can treat it correctly. It is required on any field that holds personal or customer data. The default value `SystemMetadata` means "no user or customer data" — applying it to a field that actually holds PII (an email address, a customer name, an employee code) is an under-classification and a privacy bug, even though the code still compiles.
## Best Practice
Set `DataClassification` to the value that matches the data the field actually stores. A `Customer."E-Mail"`-style field is `CustomerContent` (data belonging to the tenant's customers); a personal identifier such as an employee number or user ID is `EndUserIdentifiableInformation` or `EndUserPseudonymousIdentifiers` depending on whether it is directly identifying. Choose the classification at field definition time — fixing it later is a schema change.
See sample: `data-classification-required-on-pii-fields.good.al`.
## Anti Pattern
Declaring a field that stores PII with `DataClassification = SystemMetadata` to silence the compiler warning. The field compiles but the platform now treats customer data as system metadata in telemetry, GDPR exports and admin reports.
See sample: `data-classification-required-on-pii-fields.bad.al`.

View file

@ -1,22 +0,0 @@
---
bc-version: [all]
domain: privacy
keywords: [dataclassification, table-field, page, api-page, scope]
technologies: [al]
countries: [w1]
application-area: [all]
---
# DataClassification is a table-field property, not a page property
## Description
DataClassification governs how the platform handles a field's data in telemetry, data-subject requests, and retention tooling. It is declared on the table field, not on the page that displays the field. Pages — card pages, list pages, API pages — simply render fields sourced from a table. A privacy issue with classification is always an issue on the table definition; the page is a display surface.
## Best Practice
Flag missing or wrong DataClassification on the table field where the data lives. When a field is exposed through an API page or any other page type, the source table's classification governs. Do not report the same issue on every page that happens to include the field.
## Anti Pattern
Reporting a privacy finding on `page 50100 "Customer API"` because it exposes an email field, rather than on `table Customer`'s email field. Fix at the source; the page is not the offender and the same correction applied per-page produces churn without changing the data-classification story.

View file

@ -1,13 +0,0 @@
tableextension 50911 "Privacy Sample IS Bad" extends "Sales & Receivables Setup"
{
fields
{
// Refactor moves the delta URL out of encrypted IsolatedStorage into a
// plain table field. Value is now plaintext in SQL, unscoped, indistinguishable
// from non-sensitive content.
field(50100; "Delta Url"; Text[250])
{
DataClassification = EndUserPseudonymousIdentifiers;
}
}
}

View file

@ -1,10 +0,0 @@
codeunit 50910 "Privacy Sample IS Good"
{
procedure StoreDeltaUrl(DeltaUrl: Text)
var
DeltaKeyTok: Label 'SyncDeltaUrl', Locked = true;
begin
// Sensitive delta URL remains encrypted and scoped to the extension.
IsolatedStorage.SetEncrypted(DeltaKeyTok, DeltaUrl, DataScope::Company);
end;
}

View file

@ -1,26 +0,0 @@
---
bc-version: [all]
domain: privacy
keywords: [isolatedstorage, encryption, tokens, refactor, regression]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Do not move PII or secrets from IsolatedStorage to plain table fields
## Description
IsolatedStorage with SetEncrypted keeps sensitive values — tokens, URLs carrying identifiers, delta cursors with embedded user context — encrypted at rest and scoped to the extension. Moving the same value to a normal table field is a refactor that looks structural but is a privacy and security regression: the value is now plaintext in SQL, visible to every reader of that table, backed up and replicated as ordinary business data. Reviews of existing integrations frequently see this change justified as "easier to query" — the concern is the storage model, not the ergonomics.
## Best Practice
Keep tokens, secrets, personal-context URLs, and similar sensitive values in IsolatedStorage (SetEncrypted) or Azure Key Vault. When a refactor moves the value, require an explicit justification and a mitigating control (restricted-read permission set, value-level encryption, redaction in the access path). Otherwise leave it where it was.
See sample: `do-not-move-pii-from-isolated-storage-to-plain-fields.good.al`.
## Anti Pattern
A diff that deletes an `IsolatedStorage.SetEncrypted` call and writes the same value into a new `Text` column on a business table. The value is now unencrypted, unscoped, and indistinguishable from non-sensitive content to any caller reading the table.
See sample: `do-not-move-pii-from-isolated-storage-to-plain-fields.bad.al`.

View file

@ -0,0 +1,10 @@
codeunit 50205 "Privacy Sample Direct Error"
{
procedure ValidateCustomer(var Customer: Record Customer)
var
InvalidEmailErr: Label 'Customer %1 has an invalid e-mail address: %2.', Comment = '%1 = Customer No., %2 = E-Mail';
begin
if not Customer."E-Mail".Contains('@') then
Error(InvalidEmailErr, Customer."No.", Customer."E-Mail");
end;
}

View file

@ -0,0 +1,24 @@
---
bc-version: [all]
domain: privacy
keywords: [error, strsubstno, direct-substitution, telemetry, classification, label]
technologies: [al]
countries: [w1]
application-area: [all]
---
# `Error()` with direct substitution parameters is always safe for telemetry
## Description
When `Error()` is called with a format string and direct substitution parameters (`%1`, `%2`, …), the BC platform intercepts the call, inspects each parameter individually, and applies the `DataClassification` of the source field — stripping or masking sensitive data before writing the message to telemetry. This is true regardless of whether a parameter is a record field reference, a local variable, a function return value, or any other expression. Patterns such as `Error('Invalid email: %1', Customer."E-Mail")` are therefore safe even when the parameter is PII: the platform sees `Customer."E-Mail"` as a `CustomerContent` field reference and handles it correctly.
## Best Practice
Pass values to `Error()` as direct substitution parameters — either inline or via a `Label` with `Comment = '%1 = …'` placeholders. Let the platform do the per-parameter classification. This works equally well for record fields, local text variables, and document IDs.
See sample: `error-direct-substitution-safe-for-telemetry.good.al`.
## Anti Pattern
Treating any `Error()` call that mentions PII as a leak. A review skill that flags `Error('Invalid email: %1', EmailAddress)` is wrong; the platform handles that pattern correctly. The only `Error()` shape that genuinely leaks PII to telemetry is the pre-built `StrSubstNo` form covered in `avoid-strsubstno-prebuild-before-error.md`.

View file

@ -1,16 +0,0 @@
codeunit 50903 "Privacy Sample ErrorVsMsg Bad"
{
procedure ConfirmThenFail(var Customer: Record Customer)
var
ConfirmQst: Label 'Send welcome email to %1 at %2?', Comment = '%1 = name, %2 = email';
FailureWithPiiErr: Text;
begin
if not Confirm(ConfirmQst, false, Customer.Name, Customer."E-Mail") then
exit;
// Pre-built Text with PII, passed to Error: customer name and email reach telemetry.
FailureWithPiiErr := StrSubstNo(
'Could not send welcome to %1 at %2.', Customer.Name, Customer."E-Mail");
Error(FailureWithPiiErr);
end;
}

View file

@ -1,15 +0,0 @@
codeunit 50902 "Privacy Sample ErrorVsMsg Good"
{
procedure ConfirmThenFail(var Customer: Record Customer)
var
ConfirmQst: Label 'Send welcome email to %1 at %2?', Comment = '%1 = name, %2 = email';
GenericFailureErr: Label 'The welcome email could not be sent.';
begin
// Confirm is not logged to telemetry. PII in the prompt is fine.
if not Confirm(ConfirmQst, false, Customer.Name, Customer."E-Mail") then
exit;
// Error is logged. Keep PII out of the message.
Error(GenericFailureErr);
end;
}

View file

@ -1,26 +0,0 @@
---
bc-version: [all]
domain: privacy
keywords: [error, message, confirm, notification, telemetry, pii]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Error logs to telemetry; Message, Confirm, and Notification do not
## Description
The privacy concern with user-facing text is not what the authenticated user sees — it is what the platform exports to telemetry. Error is captured automatically; Message, Confirm, StrMenu, and Notification are not. Reviews that flag PII in any user-facing dialog over-report. Reviews that ignore PII in Error under-report. The distinction is the delivery surface, not the presence of a person's name on screen.
## Best Practice
Free-text business content — customer names, email addresses, document numbers — is acceptable in Message, Confirm, and Notification. Treat Error text as if it will be read by telemetry consumers, because it will be, but use direct Error substitution rather than pre-building the message. `Error(MyErr, EmailAddress)` is telemetry-safe; `Error(StrSubstNo(..., EmailAddress))` is not.
See sample: `error-is-logged-to-telemetry-message-is-not.good.al`.
## Anti Pattern
Embedding customer emails, phone numbers, addresses, or names as literals in an Error label or baking them into a Text value with StrSubstNo before calling Error. The user also sees Message and Confirm, but those are not logged. Error is logged, so dynamic customer data must stay as direct substitution arguments.
See sample: `error-is-logged-to-telemetry-message-is-not.bad.al`.

View file

@ -0,0 +1,22 @@
---
bc-version: [all]
domain: privacy
keywords: [error, message, confirm, notification, telemetry, logging, ui-dialog]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Only `Error()` is logged to telemetry — `Message`, `Confirm`, `Notification` are not
## Description
The privacy concern with dialog APIs is not what the signed-in user sees on the screen — it is what the platform writes to telemetry. The BC platform automatically captures `Error()` invocations in the telemetry stream; it does not capture `Message()`, `Confirm()` or `Notification` calls. That asymmetry is the reason privacy review focuses on `Error()` text and ignores the other dialog APIs: a `Message` that shows a customer's email to the signed-in user reveals nothing they were not already entitled to see, while an `Error` carrying the same email leaks it to a separate, longer-lived telemetry destination.
## Best Practice
Treat `Error()` as a telemetry surface, not just a UI surface — review the message text and parameters with the same scrutiny you apply to `Session.LogMessage`. Treat `Message()`, `Confirm()`, and `Notification` as pure UI: showing business data the user is permissioned for is normal functionality.
## Anti Pattern
Flagging `Message`/`Confirm`/`Notification` calls for "showing PII" — they are not logged to telemetry, and the user already has permission to the underlying data. The inverse anti-pattern is treating `Error()` as harmless because the user sees only a dialog: the message is also written verbatim to telemetry.

View file

@ -0,0 +1,12 @@
codeunit 50215 "Privacy Sample FeatureTelemetry Bad"
{
procedure LogDocumentReleased(ExpenseHeader: Record "Sales Header"; var User: Record User)
var
FeatureTelemetry: Codeunit "Feature Telemetry";
CustomDimensions: Dictionary of [Text, Text];
begin
CustomDimensions.Add('EmployeeNo', ExpenseHeader."Sell-to Customer No.");
CustomDimensions.Add('UserName', User."Full Name");
FeatureTelemetry.LogUsage('0000EA1', 'Expense Agent', 'Document Released', CustomDimensions);
end;
}

View file

@ -0,0 +1,10 @@
codeunit 50214 "Privacy Sample FeatureTelemetry Good"
{
procedure LogUptake()
var
FeatureTelemetry: Codeunit "Feature Telemetry";
begin
FeatureTelemetry.LogUptake('0000EA2', 'Expense Agent',
Enum::"Feature Uptake Status"::"Set up");
end;
}

View file

@ -0,0 +1,26 @@
---
bc-version: [all]
domain: privacy
keywords: [feature-telemetry, customdimensions, logusage, loguptake, logerror, pii, euii, eupi]
technologies: [al]
countries: [w1]
application-area: [all]
---
# `FeatureTelemetry` `CustomDimensions` follow the same privacy rules as `Session.LogMessage`
## Description
`Codeunit "Feature Telemetry"` is the second telemetry surface in AL. Its methods — `LogUsage()`, `LogUptake()` and `LogError()` — each accept a `CustomDimensions` dictionary parameter whose contents are sent to telemetry as-is. The platform does not classify per-dimension values for you, so any customer or employee data placed into the dictionary is logged verbatim. The privacy rules that apply to `Session.LogMessage` message text apply to every value in `CustomDimensions`: no customer or employee names, email addresses, phone numbers (`CustomerContent`/EUII); no employee codes, user IDs or user security IDs (EUPI); no user-provided content (addresses, descriptions, notes); no `GetLastErrorText()` output.
## Best Practice
Pass only non-personal context through `CustomDimensions` — feature names, status enums, counts, error codes, durations. For uptake or usage signals that do not need per-call context, prefer the parameterless overload of `LogUptake`/`LogUsage` over a `CustomDimensions` dictionary that risks accreting PII over time.
See sample: `featuretelemetry-customdimensions-no-pii.good.al`.
## Anti Pattern
`CustomDimensions.Add('EmployeeNo', ExpenseHeader."Employee No.")` followed by `FeatureTelemetry.LogUsage(...)` — the employee number is a pseudonymous user identifier (EUPI) and is now in telemetry. Same pattern with `'UserName'`, `'CustomerEmail'`, `'AttachmentName'` etc.
See sample: `featuretelemetry-customdimensions-no-pii.bad.al`.

View file

@ -0,0 +1,18 @@
tableextension 50203 "Customer Order Stats" extends Customer
{
fields
{
field(50203; "Open Order Count"; Integer)
{
FieldClass = FlowField;
CalcFormula = count("Sales Header" where("Sell-to Customer No." = field("No.")));
Caption = 'Open Order Count';
}
field(50204; "Date Filter"; Date)
{
FieldClass = FlowFilter;
Caption = 'Date Filter';
}
}
}

View file

@ -0,0 +1,24 @@
---
bc-version: [all]
domain: privacy
keywords: [flowfield, flowfilter, data-classification, systemmetadata, calculated]
technologies: [al]
countries: [w1]
application-area: [all]
---
# FlowFields and FlowFilters are classified `SystemMetadata` automatically
## Description
`FlowField` and `FlowFilter` are not stored fields — a FlowField is computed from a CalcFormula at read time and a FlowFilter is a transient filter scoped to the record variable. Because nothing is ever written to the database for these fields, the platform automatically classifies them as `DataClassification = SystemMetadata` and AL does not require — or expect — the developer to set `DataClassification` on them. A FlowField that surfaces PII (e.g., a sum or lookup over a `CustomerContent` table) is still `SystemMetadata` at the FlowField level; the privacy classification lives on the underlying stored field that the CalcFormula references.
## Best Practice
Do not declare `DataClassification` on `FieldClass = FlowField` or `FieldClass = FlowFilter` fields — the inherited `SystemMetadata` is correct and the property is redundant. If a FlowField exposes sensitive data, ensure the underlying source field has the right `DataClassification`; that is where the platform reads classification from for GDPR and telemetry purposes.
See sample: `flowfield-flowfilter-classification-systemmetadata.good.al`.
## Anti Pattern
Flagging a FlowField for "missing `DataClassification`" or trying to override it to `CustomerContent` because the formula references customer data. The platform's automatic `SystemMetadata` value is the documented, intentional behavior for non-stored fields; overriding it adds nothing and misrepresents the field as if it were stored.

View file

@ -1,22 +0,0 @@
---
bc-version: [all]
domain: privacy
keywords: [flowfield, flowfilter, dataclassification, systemmetadata, default]
technologies: [al]
countries: [w1]
application-area: [all]
---
# FlowFields and FlowFilters automatically inherit DataClassification SystemMetadata
## Description
FlowFields and FlowFilters are virtual — they carry no stored data of their own, and their values are computed on demand from the source table the CalcFormula references. The platform classifies them as SystemMetadata automatically and does not require (or respect) a per-field DataClassification declaration. Flagging a FlowField as missing DataClassification, or as under-classified because the computed value may be CustomerContent, is a false positive: the underlying source field carries the classification that matters, and that is what telemetry and compliance tooling inspects.
## Best Practice
Leave DataClassification off FlowFields and FlowFilters. If the computed value is sensitive, the fix is to ensure the source table's field has the correct classification. Verify source-field classification rather than trying to re-classify the computed view.
## Anti Pattern
Reporting "missing DataClassification" on a FlowField, or attempting to set a FlowField's DataClassification to CustomerContent because the SUM aggregates a sensitive amount. The declaration has no effect; the platform uses the source-field classification.

View file

@ -0,0 +1,17 @@
codeunit 50209 "Privacy Sample GetLastError Bad"
{
procedure AddAttachment()
var
ErrorMsg: Text;
begin
if not TryAddAttachment() then begin
ErrorMsg := StrSubstNo('Attachment failed: %1', GetLastErrorText(true));
Error(ErrorMsg);
end;
end;
[TryFunction]
local procedure TryAddAttachment()
begin
end;
}

View file

@ -0,0 +1,16 @@
codeunit 50208 "Privacy Sample GetLastError Good"
{
procedure AddAttachmentSafely()
var
AttachmentFailedErr: Label 'Failed to add email attachment. Please try again.';
begin
if not TryAddAttachment() then
Error(AttachmentFailedErr);
end;
[TryFunction]
local procedure TryAddAttachment()
begin
// ... attachment logic that may fail with a customer-data-bearing error ...
end;
}

View file

@ -0,0 +1,26 @@
---
bc-version: [all]
domain: privacy
keywords: [getlasterrortext, error, strsubstno, telemetry, customer-data, attachment]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Treat `GetLastErrorText()` as potential customer content
## Description
`GetLastErrorText()` returns the text of the last error that occurred in the context where it is called. That text routinely contains customer content — field values that triggered the validation, record keys, customer names, file names from upload failures, and similar fragments lifted from the failing operation. Re-emitting it through `StrSubstNo` into `Error()` bakes that customer data into a single plain-text parameter that the platform can no longer classify, so it is logged verbatim to telemetry (the same problem as any other `StrSubstNo`-pre-built error — see `avoid-strsubstno-prebuild-before-error.md`).
## Best Practice
When the goal is to surface a recoverable failure to the user, raise a generic message that does not embed `GetLastErrorText()` content, and log technical detail separately via `Session.LogMessage` with the correct `DataClassification`. If you must propagate the inner error verbatim, re-raise it as a direct parameter of `Error()` (e.g., `Error('%1', GetLastErrorText())`) rather than concatenating with `StrSubstNo` so the platform can apply its own handling.
See sample: `getlasterrortext-customer-content-in-errors.good.al`.
## Anti Pattern
`ErrorMsg := StrSubstNo('Attachment failed: %1', GetLastErrorText(true)); Error(ErrorMsg);` — the inner error text may carry filenames or record values, and `StrSubstNo` strips the platform's ability to filter them before they hit telemetry.
See sample: `getlasterrortext-customer-content-in-errors.bad.al`.

View file

@ -1,22 +0,0 @@
---
bc-version: [all]
domain: privacy
keywords: [memory, dictionary, list, temporary-record, scope, false-positive]
technologies: [al]
countries: [w1]
application-area: [all]
---
# In-memory variables are not a privacy concern in Business Central
## Description
Business Central runs in a managed server environment. Local variables, Dictionary, List, and temporary Record buffers exist only for the duration of the request or session; the runtime reclaims them when the scope exits. Memory dumps are not a realistic threat vector in this architecture, and flagging an in-memory collection of customer emails or names as a privacy issue misstates the product's security model.
## Best Practice
Focus privacy review on persistence, transit, and telemetry: what is written to tables, sent over the network, or logged. Treat in-memory handling of personal data as normal business functionality. When an in-memory buffer is copied into IsolatedStorage, a table, or a telemetry call, that downstream write is what gets reviewed.
## Anti Pattern
Flagging `Dictionary of [Code[20], Text]`, `List of [Text]`, or `Record Customer temporary` variables that hold customer data during a calculation as a privacy concern. The flag is a false positive that trains authors to avoid a normal pattern and distracts from the persistent storage that does matter.

View file

@ -0,0 +1,22 @@
---
bc-version: [all]
domain: privacy
keywords: [in-memory, dictionary, list, temporary-table, variable, memory-dump]
technologies: [al]
countries: [w1]
application-area: [all]
---
# In-memory variables, dictionaries, lists and temporary tables are not a privacy concern
## Description
AL runs in a managed server environment. Local variables, `Dictionary`, `List`, temporary `Record` variables, and other in-process data structures exist only for the duration of the request or session and are released by the runtime when it ends — they are not persisted, not visible across sessions, and not exposed outside the server process. Memory dumps are not a realistic threat vector against Business Central's hosted architecture, so holding business data (emails, names, addresses, document content) in these structures while processing a request is normal and expected.
## Best Practice
Use whatever in-memory shape (`Dictionary`, `List`, temporary tables, plain variables) the algorithm needs. The privacy review applies to *persistent* surfaces — table fields, telemetry, outgoing HTTP — not to per-request memory.
## Anti Pattern
Flagging a `Dictionary of [Text, Text]` populated with customer emails, or a temporary `Record Customer` holding rows mid-processing, as a privacy leak. These structures are scoped to the request and do not leave the server's memory.

View file

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

View file

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

View file

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

View file

@ -0,0 +1,22 @@
---
bc-version: [all]
domain: privacy
keywords: [data-migration, hybridsl, hybridgp, hybridbc, destination-classification, ssn, tin]
technologies: [al]
countries: [w1]
application-area: [all]
---
# In data migration code, classify the destination — not the migration itself
## Description
Migration codeunits such as `HybridSL`, `HybridGP`, and `HybridBC` exist to copy sensitive data — TINs, Federal IDs, social security numbers, financial records — from a source system into Business Central. The fact that PII flows through these codeunits is the entire point of their existence, not a defect. The privacy concern is whether the destination field where the data lands carries the correct `DataClassification`. If it does, the migration is doing its job; if it doesn't, the right fix is on the destination table field, never on the migration code that writes to it.
## Best Practice
When reviewing a migration codeunit, trace each `Dest."<Field>" := Source."<Field>"` assignment to the destination field's `DataClassification`. Confirm that fields receiving PII (SSNs, Federal IDs, customer names, addresses) are classified `EndUserIdentifiableInformation` or `CustomerContent` as appropriate — and not left as `SystemMetadata` or `ToBeClassified`.
## Anti Pattern
Flagging the migration code itself for "processing sensitive data" or recommending that it filter, hash, or skip PII fields — these tables exist to migrate that data. The actionable finding is always on the destination field's classification, not on the migration's assignment statement.

View file

@ -0,0 +1,21 @@
codeunit 50213 "Privacy Sample Telemetry Bad"
{
procedure LogCustomerProcessed(var Customer: Record Customer)
begin
Session.LogMessage('0000', StrSubstNo('Processed %1', Customer.Name), Verbosity::Normal,
DataClassification::SystemMetadata, TelemetryScope::All,
'Category', 'Privacy');
end;
procedure LogFileError(FileName: Text)
begin
Session.LogMessage('0001', StrSubstNo('Error processing file %1', FileName), Verbosity::Error,
DataClassification::SystemMetadata, TelemetryScope::All);
end;
procedure LogEmployeeUpdate(EmployeeCode: Code[20])
begin
Session.LogMessage('0002', StrSubstNo('Employee %1 updated record', EmployeeCode), Verbosity::Normal,
DataClassification::SystemMetadata, TelemetryScope::All);
end;
}

View file

@ -0,0 +1,15 @@
codeunit 50212 "Privacy Sample Telemetry Good"
{
procedure LogCustomerProcessed(var Customer: Record Customer)
begin
Session.LogMessage('0000', 'Customer record processed', Verbosity::Normal,
DataClassification::SystemMetadata, TelemetryScope::All,
'Category', 'Privacy');
end;
procedure LogFileError()
begin
Session.LogMessage('0001', 'Error processing uploaded file', Verbosity::Error,
DataClassification::SystemMetadata, TelemetryScope::All);
end;
}

View file

@ -0,0 +1,26 @@
---
bc-version: [all]
domain: privacy
keywords: [telemetry, session-logmessage, strsubstno, pii, customer-data, employee-code, filename]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Do not embed customer data in the telemetry message text
## Description
`Session.LogMessage`'s message argument is a plain `Text`. Unlike `Error()`, the platform does not inspect this string field-by-field — whatever is in the text is what telemetry receives. So a call that builds the message via `StrSubstNo` from customer-bearing fields ships those values to telemetry verbatim, regardless of the `DataClassification` argument on the same call. Flagged content includes customer names, email addresses, phone numbers, addresses, employee codes or IDs, attachment filenames, user-provided text that may carry PII, and dumps of `Record` content.
## Best Practice
Keep the telemetry message a static, non-personal string ("Customer record processed", "Error processing uploaded file"). When structured context is genuinely needed, attach it through custom dimensions, where individual values can be reviewed and classified at the dimension level rather than baked into a free-text message.
See sample: `no-pii-in-telemetry-message-string.good.al`.
## Anti Pattern
`Session.LogMessage('0000', StrSubstNo('Processed %1', Customer.Name), ...)` — the customer name is in telemetry the moment the line runs. Detection signal: a `StrSubstNo` whose result is the second argument of `Session.LogMessage`. The same shape with `FileName`, `EmployeeCode`, or any record field is the same problem.
See sample: `no-pii-in-telemetry-message-string.bad.al`.

View file

@ -1,18 +0,0 @@
table 50909 "Privacy Sample Override Bad"
{
DataClassification = SystemMetadata;
fields
{
field(1; "Entry No."; Integer) { }
// Customer name inherits SystemMetadata from the table. Subject-access
// and retention tooling treats the value as system housekeeping.
field(2; "Customer Name"; Text[100]) { }
field(3; "E-Mail"; Text[80]) { }
field(4; "Logged At"; DateTime) { }
}
keys
{
key(PK; "Entry No.") { Clustered = true; }
}
}

View file

@ -1,23 +0,0 @@
table 50908 "Privacy Sample Override Good"
{
DataClassification = SystemMetadata;
fields
{
field(1; "Entry No."; Integer) { }
field(2; "Customer Name"; Text[100])
{
// Table default is SystemMetadata; this field is personal data.
DataClassification = CustomerContent;
}
field(3; "E-Mail"; Text[80])
{
DataClassification = CustomerContent;
}
field(4; "Logged At"; DateTime) { }
}
keys
{
key(PK; "Entry No.") { Clustered = true; }
}
}

View file

@ -1,26 +0,0 @@
---
bc-version: [all]
domain: privacy
keywords: [dataclassification, inheritance, table-level, field-level, override]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Override inherited DataClassification when a field doesn't fit the table default
## Description
When a table declares `DataClassification` at the table level, every field inherits that value unless the field declares its own. This is efficient for homogeneous tables — a SystemMetadata log table whose fields are all system-generated, a CustomerContent transaction table whose fields are all business data. It is a privacy regression when a table is classified SystemMetadata but contains a field that holds personal data: the field silently inherits the wrong classification, and telemetry tooling treats its content as safe to log when it is not.
## Best Practice
Review every field on a table with a table-level DataClassification. Fields whose content matches the table's default need no per-field declaration. Fields that carry a different kind of data — a customer name on an otherwise-system-metadata log table, a personal identifier on a mixed-content table — must declare their own DataClassification that overrides the table default.
See sample: `override-inherited-dataclassification-per-field.good.al`.
## Anti Pattern
A table declared `DataClassification = SystemMetadata` with fields like `Customer Name`, `E-Mail`, `Phone No.` — the fields inherit SystemMetadata, which is wrong for CustomerContent. Subject-access-request and retention tooling treats the personal data as system housekeeping.
See sample: `override-inherited-dataclassification-per-field.bad.al`.

View file

@ -0,0 +1,22 @@
---
bc-version: [all]
domain: privacy
keywords: [page, card, list, api, listpart, permission-system, display, ui-dialog]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Displaying fields on a page (or in a UI dialog) is not a privacy concern
## Description
Every page in Business Central — `Card`, `List`, `API`, `ListPart`, request pages — renders data to an authenticated user who has been granted permission to see it. The BC permission system, not the page definition, controls who sees what; once a user is permissioned to a table, displaying any field of that table is normal business functionality. The same logic extends to `Message`, `Notification` and `Confirm` dialogs: the signed-in user already has access to the data the dialog is showing them. Privacy review for pages and dialogs is therefore the wrong layer — the actionable findings live on the underlying data (table-field classification, telemetry message text, outbound HTTP consent), not on the UI.
## Best Practice
When asked "is it OK to show this email/name/employee code on this page?", the answer is yes — provided the user has permission to the underlying record. Drive privacy concerns to the data layer (classification, telemetry, external transfer) rather than the UI layer.
## Anti Pattern
Flagging an API page, list, card, or notification for surfacing customer-bearing fields (`E-Mail`, `Name`, `Phone No.`, audit fields, `User ID`). The permission system governs visibility; the page does not.

View file

@ -1,22 +0,0 @@
---
bc-version: [all]
domain: privacy
keywords: [page, display, permission, authenticated, false-positive]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Pages displaying data to permitted users are not a privacy concern
## Description
Every page in Business Central displays data to an authenticated user who holds the permissions required to see it. The permission system — table permissions, entitlements, field-level restrictions where configured — is the access-control boundary. Flagging a page for showing customer emails, names, addresses, document numbers, or system audit fields treats display as a leak when it is the product's intended function.
## Best Practice
Privacy review of pages is about data classification on the source table and about consent on outgoing integrations reached through page actions. Displaying business data to a user with permission to view it is correct behaviour, including on API pages that are gated by the same permission model.
## Anti Pattern
Reporting "customer email is shown on the page" or "user ID visible in the list" as privacy findings. The finding does not reflect a privacy regression and redirects the author toward hiding data that the permitted user is entitled to see. The same logic produces noise on Confirm, Message, and Notification that surface business identifiers.

View file

@ -0,0 +1,13 @@
codeunit 50217 "Privacy Sample Consent Bad"
{
procedure SendDataToExternalService(Customer: Record Customer)
var
HttpClient: HttpClient;
Content: HttpContent;
Response: HttpResponseMessage;
begin
Content.WriteFrom(StrSubstNo('{"email":"%1","name":"%2"}',
Customer."E-Mail", Customer.Name));
HttpClient.Post('https://api.externalservice.com/sync', Content, Response);
end;
}

View file

@ -0,0 +1,22 @@
codeunit 50216 "Privacy Sample Consent Good"
{
procedure SendDataToExternalService(Customer: Record Customer)
var
PrivacyNotice: Codeunit "Privacy Notice";
PrivacyNoticeRegistrations: Codeunit "Privacy Notice Registrations";
HttpClient: HttpClient;
Content: HttpContent;
Response: HttpResponseMessage;
PrivacyConsentRequiredErr: Label 'Privacy notice consent is required for this integration.';
begin
if PrivacyNotice.GetPrivacyNoticeApprovalState(
PrivacyNoticeRegistrations.GetExchangePrivacyNoticeId())
<> "Privacy Notice Approval State"::Agreed
then
Error(PrivacyConsentRequiredErr);
Content.WriteFrom(StrSubstNo('{"email":"%1","name":"%2"}',
Customer."E-Mail", Customer.Name));
HttpClient.Post('https://api.externalservice.com/sync', Content, Response);
end;
}

View file

@ -0,0 +1,26 @@
---
bc-version: [all]
domain: privacy
keywords: [privacy-notice, consent, http-client, outgoing-request, external-service, getprivacynoticeapprovalstate]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Outgoing requests to external services require a Privacy Notice consent check
## Description
Business Central ships a built-in Privacy Notice framework that the admin uses to grant or withhold per-integration consent for sending data to external services. The relevant API surface is `Codeunit "Privacy Notice"` (consent checks via `GetPrivacyNoticeApprovalState()`), `Codeunit "Privacy Notice Registrations"` (well-known notice IDs for integrations such as Exchange, OneDrive, Teams), and the `Enum "Privacy Notice Approval State"` with values `Agreed`, `Disagreed`, and `Not Set`. The admin UI is the **Privacy Notices Status** page. The compliance concern in code review is therefore not that personal data is included in an outgoing HTTP body — that is normal business functionality — but that the code path issuing the request contains no `PrivacyNotice.GetPrivacyNoticeApprovalState(...)` check.
## Best Practice
Before issuing an outgoing HTTP request to an external service, verify `PrivacyNotice.GetPrivacyNoticeApprovalState(<integration id>) = "Privacy Notice Approval State"::Agreed`. The check does not have to live next to the `HttpClient.Post` call — it can sit anywhere upstream in the same code path (for example in the page's `OnOpenPage`, in a wizard step, or in a setup action) as long as no execution path reaches the request without passing through it.
See sample: `privacy-notice-consent-for-external-data-transfer.good.al`.
## Anti Pattern
A `procedure SendDataToExternalService(...)` that posts customer data to an external endpoint with no `PrivacyNotice.GetPrivacyNoticeApprovalState` anywhere upstream. The same anti-pattern applies in reverse: removing an existing privacy-notice check from code that still issues the external call.
See sample: `privacy-notice-consent-for-external-data-transfer.bad.al`.

View file

@ -0,0 +1,11 @@
codeunit 50218 "Privacy Sample Register Integration"
{
[EventSubscriber(ObjectType::Codeunit, Codeunit::"Privacy Notice Registrations", 'OnRegisterPrivacyNotices', '', false, false)]
local procedure OnRegisterPrivacyNotices(var TempPrivacyNotice: Record "Privacy Notice" temporary)
var
PrivacyNotice: Codeunit "Privacy Notice";
begin
PrivacyNotice.CreatePrivacyNoticeForIntegration(
'My External Sync', 'External Customer Sync Service');
end;
}

View file

@ -0,0 +1,24 @@
---
bc-version: [all]
domain: privacy
keywords: [privacy-notice-registrations, integration, register, exchange, onedrive, teams, notice-id]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Register every new external integration with `Privacy Notice Registrations`
## Description
`Codeunit "Privacy Notice Registrations"` is the registry of integrations whose consent state the platform tracks. Built-in integrations such as Exchange, OneDrive and Teams already have notice IDs exposed via accessor methods on this codeunit (`GetExchangePrivacyNoticeId`, etc.); a new integration introduced by an extension must add itself to the registry so that the admin can grant or withhold consent on the **Privacy Notices Status** page. Without registration, there is nothing for `Codeunit "Privacy Notice"` to return an approval state for — the call cannot meaningfully gate the outbound request.
## Best Practice
When introducing a new outbound integration: pick a stable notice ID, register it via `Privacy Notice Registrations`, and then gate every outbound call with `PrivacyNotice.GetPrivacyNoticeApprovalState(<that id>)` as described in `privacy-notice-consent-for-external-data-transfer.md`.
See sample: `register-integration-in-privacy-notice-registrations.good.al`.
## Anti Pattern
Shipping a new outbound integration without registering it. Even if the code calls `GetPrivacyNoticeApprovalState`, the admin has no surface to express consent — the integration is effectively unmanaged from a privacy-notice standpoint.

View file

@ -1,14 +0,0 @@
codeunit 50905 "Privacy Sample Consent Bad"
{
procedure SyncToPartner(var Customer: Record Customer)
var
Client: HttpClient;
Content: HttpContent;
Response: HttpResponseMessage;
begin
// Customer email and name sent externally with no Privacy Notice check
// anywhere in the reachable code path.
Content.WriteFrom(Customer."E-Mail");
Client.Post('https://partner.example.com/sync', Content, Response);
end;
}

View file

@ -1,20 +0,0 @@
codeunit 50904 "Privacy Sample Consent Good"
{
procedure SyncToPartner(var Customer: Record Customer)
var
PrivacyNotice: Codeunit "Privacy Notice";
Client: HttpClient;
Content: HttpContent;
Response: HttpResponseMessage;
PartnerNoticeIdTok: Label 'Contoso-PartnerSync', Locked = true;
ConsentRequiredErr: Label 'Consent is required before syncing to the external partner.';
begin
if PrivacyNotice.GetPrivacyNoticeApprovalState(PartnerNoticeIdTok, false) <>
"Privacy Notice Approval State"::Agreed
then
Error(ConsentRequiredErr);
Content.WriteFrom(Customer."No.");
Client.Post('https://partner.example.com/sync', Content, Response);
end;
}

View file

@ -1,26 +0,0 @@
---
bc-version: [all]
domain: privacy
keywords: [privacy-notice, consent, gdpr, httpclient, outgoing-request]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Check Privacy Notice consent before outgoing requests with customer data
## Description
Business Central ships a Privacy Notice framework for user consent to third-party integrations. When code sends personal data (emails, names, addresses) to an external service, the concern is not whether the data itself is compliant — the product handles that — but whether the code path has verified the user has agreed to the integration. Missing consent checks on new or modified outgoing paths is the privacy issue to flag; the presence of PII in the payload is not.
## Best Practice
Before an outgoing HttpClient call that carries customer data, verify consent via `Codeunit "Privacy Notice".GetPrivacyNoticeApprovalState()` for the integration's registered notice id. The check may live upstream (page OnOpenPage, wizard step) as long as every path that reaches the external call passes through it. Register new integrations via `Codeunit "Privacy Notice Registrations"`.
See sample: `require-privacy-notice-consent-before-outgoing-requests.good.al`.
## Anti Pattern
Adding or modifying an outgoing integration and sending customer data without any `Privacy Notice` check in the reachable code path. Removing an existing consent check from an integration that still sends data externally falls in the same category.
See sample: `require-privacy-notice-consent-before-outgoing-requests.bad.al`.

View file

@ -1,22 +1,22 @@
---
bc-version: [all]
domain: privacy
keywords: [tobeclassified, dataclassification, release, gdpr, placeholder]
keywords: [tobeclassified, data-classification, release, appsource, development]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Resolve ToBeClassified before release
# Resolve every `ToBeClassified` before release
## Description
`DataClassification = ToBeClassified` is a development marker, not a releasable privacy state. It tells reviewers and tooling that the field still needs classification work. Shipping it prevents data-subject, retention, and telemetry tooling from making a correct decision about the field.
`DataClassification = ToBeClassified` is the sentinel value the AL compiler accepts while a developer has not yet decided what a new field actually stores. It exists for the development phase only and must be resolved to a real classification (`CustomerContent`, `EndUserIdentifiableInformation`, `EndUserPseudonymousIdentifiers`, `AccountData`, `OrganizationIdentifiableInformation` or `SystemMetadata`) before the code ships. A released field left at `ToBeClassified` tells the platform "we have not classified this data" — which means GDPR data-subject requests, telemetry and audit reports cannot reason about it.
## Best Practice
Replace every `ToBeClassified` value with the narrowest accurate classification before the PR ships to customers. If the field inherits a correct table-level DataClassification, remove the placeholder rather than leaving a field-level `ToBeClassified` override.
Treat `ToBeClassified` as a TODO marker that fails release readiness. Sweep new table objects and table extensions for it before submitting a build for publication. If the right classification is genuinely unclear, decide between `CustomerContent` and `EndUserIdentifiableInformation` from the data's content, not from convenience.
## Anti Pattern
Treating ToBeClassified as a safe default because the field is new or because the final classification is uncertain. Uncertainty should bias toward a stronger classification, not toward an unresolved placeholder.
Leaving `ToBeClassified` in a shipped extension. Reviewers who treat the value as "I'll figure it out later" ship a field whose privacy posture is undefined for every customer that installs the app.

View file

@ -1,16 +0,0 @@
codeunit 50907 "Privacy Sample LastErr Bad"
{
procedure LogFailure()
var
CategoryTok: Label 'Sync', Locked = true;
FailureTxt: Label 'Operation failed: %1', Comment = '%1 = last error text';
begin
// GetLastErrorText(true) carries the call stack and field values from
// the failing context. Declared as SystemMetadata but the payload is CustomerContent.
Session.LogMessage(
'0000ABC', StrSubstNo(FailureTxt, GetLastErrorText(true)),
Verbosity::Error,
DataClassification::SystemMetadata,
TelemetryScope::ExtensionPublisher, 'Category', CategoryTok);
end;
}

View file

@ -1,15 +0,0 @@
codeunit 50906 "Privacy Sample LastErr Good"
{
procedure LogFailure()
var
CategoryTok: Label 'Sync', Locked = true;
GenericMsgTxt: Label 'Sync operation failed. See extended log for details.';
begin
// Generic message, no GetLastErrorText. Detail goes to an internal log
// the telemetry pipeline does not receive.
Session.LogMessage(
'0000ABC', GenericMsgTxt, Verbosity::Error,
DataClassification::SystemMetadata,
TelemetryScope::ExtensionPublisher, 'Category', CategoryTok);
end;
}

View file

@ -1,26 +0,0 @@
---
bc-version: [all]
domain: privacy
keywords: [getlasterrortext, telemetry, callstack, dataclassification, pii]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Sanitize GetLastErrorText before sending to telemetry
## Description
`GetLastErrorText` and `GetLastErrorCallStack` return strings built from the failing call site's data — field values, record keys, customer names, filenames. Logging either to telemetry with `DataClassification::SystemMetadata` misstates the content: the actual values are CustomerContent or worse. The true classification is not always SystemMetadata, and silently mislabelling a CustomerContent payload as system data is the specific privacy regression to avoid.
## Best Practice
Log a generic error message and either omit GetLastErrorText entirely or classify the telemetry call as `DataClassification::CustomerContent`. Prefer `GetLastErrorText(false)` to exclude the call stack when the text is needed but the stack is not. When in doubt, log a generic summary and persist the detailed error separately in a restricted-access log the telemetry pipeline does not receive.
See sample: `sanitize-getlasterrortext-before-telemetry.good.al`.
## Anti Pattern
`Session.LogMessage(..., StrSubstNo('Operation failed: %1', GetLastErrorText(true)), ..., DataClassification::SystemMetadata, ...)` — the classification is wrong for the payload, and the call stack typically carries customer data from the failing operation into the telemetry stream.
See sample: `sanitize-getlasterrortext-before-telemetry.bad.al`.

View file

@ -0,0 +1,7 @@
codeunit 50211 "Privacy Sample LogMessage Bad"
{
procedure LogCompleted()
begin
Session.LogMessage('0003', 'Operation completed', Verbosity::Normal);
end;
}

View file

@ -0,0 +1,8 @@
codeunit 50210 "Privacy Sample LogMessage Good"
{
procedure LogCompleted()
begin
Session.LogMessage('0003', 'Operation completed', Verbosity::Normal,
DataClassification::SystemMetadata, TelemetryScope::ExtensionPublisher);
end;
}

View file

@ -0,0 +1,26 @@
---
bc-version: [all]
domain: privacy
keywords: [session-logmessage, telemetry, data-classification, verbosity, telemetry-scope]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Every `Session.LogMessage` call must specify `DataClassification`
## Description
`Session.LogMessage` writes a record to the telemetry pipeline. The platform requires the call to carry an explicit `DataClassification` argument so that the entry can be routed and retained correctly downstream — telemetry consumers, GDPR exports, and Application Insights dashboards all rely on it. The compiler accepts overloads without the parameter (the two-argument and three-argument shapes that omit it), but for any telemetry that ships to customers, the `DataClassification`-bearing overload is the correct one.
## Best Practice
Use the overload that takes `Verbosity`, `DataClassification`, and `TelemetryScope`. For payload-free operational telemetry that does not embed customer data, `DataClassification::SystemMetadata` is the right value. Choose `TelemetryScope::ExtensionPublisher` for telemetry meant for the publishing partner only; `TelemetryScope::All` also forwards to the customer's tenant telemetry.
See sample: `session-logmessage-requires-dataclassification.good.al`.
## Anti Pattern
Calling `Session.LogMessage('0003', 'Operation completed', Verbosity::Normal)` — the overload omits `DataClassification` and leaves the platform without the information needed to classify the entry. Detection signal: a `Session.LogMessage` call whose argument list ends at `Verbosity`.
See sample: `session-logmessage-requires-dataclassification.bad.al`.

View file

@ -1,16 +0,0 @@
codeunit 50913 "Privacy Sample Telemetry Bad"
{
procedure LogProcessed(var Customer: Record Customer)
var
CategoryTok: Label 'CustomerProcessing', Locked = true;
MsgTemplateTxt: Label 'Processed customer %1', Comment = '%1 = customer name';
begin
// Declared SystemMetadata; payload is CustomerContent. The message is
// opaque text once built; the pipeline cannot redact.
Session.LogMessage(
'0000001', StrSubstNo(MsgTemplateTxt, Customer.Name),
Verbosity::Normal,
DataClassification::SystemMetadata,
TelemetryScope::All, 'Category', CategoryTok);
end;
}

View file

@ -1,17 +0,0 @@
codeunit 50912 "Privacy Sample Telemetry Good"
{
procedure LogProcessed(var Customer: Record Customer)
var
CategoryTok: Label 'CustomerProcessing', Locked = true;
ProcessedMsgTxt: Label 'Customer record processed.';
begin
// Generic message. Business identifier in a custom dimension,
// never a free-text personal name.
Session.LogMessage(
'0000001', ProcessedMsgTxt, Verbosity::Normal,
DataClassification::SystemMetadata,
TelemetryScope::ExtensionPublisher,
'Category', CategoryTok,
'CustomerNo', Customer."No.");
end;
}

View file

@ -1,26 +0,0 @@
---
bc-version: [all]
domain: privacy
keywords: [telemetry, session-logmessage, dataclassification, dimensions, pii]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Specify DataClassification on every telemetry call and keep PII out of the message
## Description
`Session.LogMessage` accepts a DataClassification parameter that governs how the platform handles the logged content in the telemetry pipeline. Omitting it is a schema violation the platform cannot repair later. Embedding personal data — emails, names, phone numbers, addresses, filenames of user uploads — in the message string also defeats classification, because the pipeline sees opaque text and cannot selectively redact. The same privacy boundary applies to other telemetry surfaces such as `Codeunit "Feature Telemetry"` custom dimensions.
## Best Practice
Pass DataClassification explicitly on every Session.LogMessage call. Keep the message a generic, non-identifying sentence and place structured values in custom dimensions where the classification applies per key. Business identifiers (Customer No., Document No., Vendor No.) are acceptable as dimensions; free-text personal data is not.
See sample: `specify-dataclassification-on-every-telemetry-call.good.al`.
## Anti Pattern
`Session.LogMessage('0001', StrSubstNo('Customer %1 processed', Customer.Name), Verbosity::Normal, DataClassification::SystemMetadata, TelemetryScope::All)` — the declared classification is SystemMetadata but the message carries CustomerContent. The payload is logged with the wrong tag; downstream consumers treat it as safe when it is not.
See sample: `specify-dataclassification-on-every-telemetry-call.bad.al`.

View file

@ -1,14 +0,0 @@
codeunit 50901 "Privacy Sample StrSubstNo Bad"
{
procedure FailCustomer(var Customer: Record Customer)
var
ErrorMsg: Text;
begin
// Platform receives a plain Text string. It cannot inspect fields,
// cannot classify, cannot strip. The email and address reach telemetry.
ErrorMsg := StrSubstNo(
'Customer %1 (%2) at %3 has invalid data',
Customer.Name, Customer."E-Mail", Customer.Address);
Error(ErrorMsg);
end;
}

View file

@ -1,11 +0,0 @@
codeunit 50900 "Privacy Sample StrSubstNo Good"
{
procedure FailCustomer(var Customer: Record Customer)
var
CustomerDataInvalidErr: Label 'Customer %1 has invalid data.', Comment = '%1 = Customer No.';
begin
// Platform sees the Label and the field reference. It inspects the
// field's DataClassification and handles telemetry correctly.
Error(CustomerDataInvalidErr, Customer."No.");
end;
}

View file

@ -1,26 +0,0 @@
---
bc-version: [all]
domain: privacy
keywords: [strsubstno, error, telemetry, dataclassification, pii]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Pre-building Error text with StrSubstNo defeats platform PII stripping
## Description
Error messages are captured by platform telemetry. When Error receives a format template and substitution arguments directly (`Error('... %1 ...', Value)`), the platform can classify and strip sensitive values before telemetry is written. This is true whether the arguments are record fields, local variables, function results, or other expressions. When the caller pre-builds the message with StrSubstNo and then passes the resulting Text to Error, the platform sees a plain string with no argument context and logs the whole thing verbatim — any PII already baked in is exported to telemetry.
## Best Practice
Pass the template and substitution arguments directly to Error. Declare the template as a Label with a Comment describing each placeholder. Do not flag direct Error substitution merely because an argument may contain a customer name, email address, or phone number; the platform intercepts those arguments before telemetry.
See sample: `strsubstno-prebuild-breaks-error-telemetry-classification.good.al`.
## Anti Pattern
Assigning the output of StrSubstNo to a Text variable and passing that variable to Error. Every substituted value is now part of an opaque string; the platform cannot classify it and logs everything.
See sample: `strsubstno-prebuild-breaks-error-telemetry-classification.bad.al`.

View file

@ -0,0 +1,16 @@
table 50202 "System Configuration Log"
{
DataClassification = SystemMetadata;
fields
{
field(1; "Entry No."; Integer) { }
field(2; "Changed By"; Code[50]) { }
field(3; "Change Description"; Text[250]) { }
}
keys
{
key(PK; "Entry No.") { Clustered = true; }
}
}

View file

@ -0,0 +1,24 @@
---
bc-version: [all]
domain: privacy
keywords: [data-classification, table-level, inheritance, override, cascading]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Table-level DataClassification cascades to every field unless overridden
## Description
`DataClassification` may be set at the table level. When it is, every field in the table inherits that classification and individual fields do not need their own `DataClassification` property. The cascade is the platform's intended way of classifying tables whose fields are homogeneous — for example, a system configuration log whose every column is `SystemMetadata`. A field only needs its own classification when its content genuinely differs from the table's default and the inherited value would be wrong.
## Best Practice
Set `DataClassification` once at the table level whenever every field in the table shares the same classification. Omit field-level `DataClassification` properties in that case. Override only on the specific fields whose data class differs from the table's — for example, a `SystemMetadata` audit table that nonetheless captures a `CustomerContent` value somewhere.
See sample: `table-level-data-classification-cascades.good.al`.
## Anti Pattern
Flagging individual fields for "missing `DataClassification`" when the table declares one — the inheritance is the correct, intentional pattern. The mirror anti-pattern is repeating the same `DataClassification` on every field of a table that already declares it at the table level; the property is redundant and adds nothing the platform did not already know.