Extract 55 knowledge articles from BC review-agent prompt

Adds 55 articles (plus 76 code samples) spanning four new domains and
two existing domains, extracted from the internal Business Central
review-agent prompt. Content was filtered against BCQuality's
remedial-knowledge premise: each article encodes BC-specific behaviour,
a CodeCop rule, a platform API semantic, or an anti-false-positive
guideline that a capable LLM would otherwise get wrong.

New domains:
- privacy (11 articles): DataClassification inheritance semantics, the
  StrSubstNo-defeats-Error-telemetry-classification pitfall, Privacy
  Notice consent for outgoing requests, anti-false-positives for pages
  and in-memory data.
- upgrade (11 articles): upgrade-codeunit structure, upgrade-tag
  lifecycle and registration, protected DB reads, DataTransfer for
  large datasets, InitValue semantics, enum-ordinal preservation,
  obsolete-workflow, first-install detection.
- ui (9 articles): caption capitalization by phrase type, tooltip voice,
  teaching-tip vs tooltip, tour-tip conventions, character limits,
  banned terms, ampersand handling, title punctuation.
- style (11 articles): label-suffix convention, API page naming,
  temporary-variable prefix, label properties (Comment/Locked), named
  invocations, FieldCaption in user messages, OptionCaption pairing,
  Error-parameter passing, `this` keyword, required parentheses, file
  naming.

Gaps in existing domains:
- performance (11 articles): production-scale table catalog (no row
  counts, per internal-data concern), anti-false-positive for bounded
  tables, guard-before-Get ordering, redundant-Get-in-OnAfterGetRecord,
  LockTable in read-only helpers, combined ModifyAll passes, writes in
  OnAfterGetRecord, SetLoadFields heuristics, temporary-table
  regressions, FlowField source-table widening, MaintainSQLIndex
  disabling SIFT.
- security (2 articles): environment-specific hardcoded GUIDs,
  ValidateTableRelation=false on user input.

Intentionally excluded: specific production P95 row-count numbers
(aggregated internal telemetry); rewritten as categorical guidance on
which tables to treat as production-scale without publishing sizes.

All articles use `bc-version: [all]` (applies to every BC version, per
the new schema sentinel). Validator passes with 0 errors / 0 warnings.
This commit is contained in:
Jesper Schulz-Wedde 2026-04-23 16:43:42 +02:00
parent 9a4198eb28
commit e570d6113f
131 changed files with 2799 additions and 0 deletions

View file

@ -0,0 +1,22 @@
---
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

@ -0,0 +1,13 @@
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

@ -0,0 +1,10 @@
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

@ -0,0 +1,26 @@
---
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,16 @@
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

@ -0,0 +1,15 @@
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

@ -0,0 +1,26 @@
---
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. Use localized Labels with the fewest possible PII placeholders, or system identifiers (SystemId, primary key values) rather than personal data.
See sample: `error-is-logged-to-telemetry-message-is-not.good.al`.
## Anti Pattern
Embedding customer emails, phone numbers, addresses, or names directly into Error strings — either as literals or via pre-built StrSubstNo output — because "the user will see this anyway." The user also sees Message and Confirm, but those are not logged. Error is.
See sample: `error-is-logged-to-telemetry-message-is-not.bad.al`.

View file

@ -0,0 +1,22 @@
---
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,22 @@
---
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,18 @@
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

@ -0,0 +1,23 @@
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

@ -0,0 +1,26 @@
---
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, 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,14 @@
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

@ -0,0 +1,20 @@
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

@ -0,0 +1,26 @@
---
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

@ -0,0 +1,16 @@
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

@ -0,0 +1,15 @@
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

@ -0,0 +1,26 @@
---
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,16 @@
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

@ -0,0 +1,17 @@
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

@ -0,0 +1,26 @@
---
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.
## 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

@ -0,0 +1,14 @@
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

@ -0,0 +1,11 @@
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

@ -0,0 +1,26 @@
---
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 field references as substitution arguments (Error('... %1 ...', Customer."No.")), the platform inspects each field's DataClassification and omits sensitive values from telemetry automatically. When the caller pre-builds the message with StrSubstNo and then passes the resulting Text to Error, the platform sees a plain string with no field context and logs the whole thing verbatim — any PII already baked in is exported to telemetry.
## Best Practice
Pass the template and the field references directly to Error. Declare the template as a Label with a Comment describing each placeholder. The platform's field-aware classification logic then takes care of what reaches telemetry.
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`.