Address review feedback on retention policy knowledge

- Scope both articles to bc-version [17..] (retention policies shipped in v17).
- Allowed-tables sample: add OnRefreshAllowedTables subscriber with a
  ForceUpdate path; the upgrade tag now gates one-time setup only.
- Default-policy sample: use Retention Policy Setup.FindOrCreateRetentionPeriod
  instead of a hand-rolled lookup-then-insert that can collide on code.
- Anti-pattern now keys on append-only tables rather than table names.
- al-privacy-review: add retention-policy tokens and deterministic routing
  for both articles, with a bounded per-table text search for delete and
  registration paths.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Jeremy Vyska 2026-09-28 09:41:25 +02:00
parent 2f3e5afac1
commit 4bef582ffe
5 changed files with 37 additions and 30 deletions

View file

@ -3,12 +3,21 @@ codeunit 50560 "Contoso Reten. Pol. Setup"
Access = Internal; Access = Internal;
procedure AddAllowedTables() procedure AddAllowedTables()
begin
AddAllowedTables(false);
end;
// ForceUpdate re-registers even after the upgrade tag is set, so the
// table comes back when an administrator refreshes the allowed tables.
procedure AddAllowedTables(ForceUpdate: Boolean)
var var
ContosoActivityLog: Record "Contoso Activity Log"; ContosoActivityLog: Record "Contoso Activity Log";
RetenPolAllowedTables: Codeunit "Reten. Pol. Allowed Tables"; RetenPolAllowedTables: Codeunit "Reten. Pol. Allowed Tables";
UpgradeTag: Codeunit "Upgrade Tag"; UpgradeTag: Codeunit "Upgrade Tag";
IsInitialSetup: Boolean;
begin begin
if UpgradeTag.HasUpgradeTag(AllowedTableTag()) then IsInitialSetup := not UpgradeTag.HasUpgradeTag(AllowedTableTag());
if not (IsInitialSetup or ForceUpdate) then
exit; exit;
if not RetenPolAllowedTables.IsAllowedTable(Database::"Contoso Activity Log") then if not RetenPolAllowedTables.IsAllowedTable(Database::"Contoso Activity Log") then
@ -17,6 +26,7 @@ codeunit 50560 "Contoso Reten. Pol. Setup"
ContosoActivityLog.FieldNo(SystemCreatedAt), ContosoActivityLog.FieldNo(SystemCreatedAt),
28); // support cases need at least four weeks of log history 28); // support cases need at least four weeks of log history
if IsInitialSetup then
UpgradeTag.SetUpgradeTag(AllowedTableTag()); UpgradeTag.SetUpgradeTag(AllowedTableTag());
end; end;
@ -24,6 +34,12 @@ codeunit 50560 "Contoso Reten. Pol. Setup"
begin begin
exit('Contoso-ActivityLogAllowedTable-20260910'); exit('Contoso-ActivityLogAllowedTable-20260910');
end; end;
[EventSubscriber(ObjectType::Codeunit, Codeunit::"Reten. Pol. Allowed Tables", OnRefreshAllowedTables, '', false, false)]
local procedure AddAllowedTablesOnRefreshAllowedTables()
begin
AddAllowedTables(true);
end;
} }
codeunit 50561 "Contoso Reten. Pol. Install" codeunit 50561 "Contoso Reten. Pol. Install"

View file

@ -1,7 +1,7 @@
--- ---
bc-version: [all] bc-version: [17..]
domain: privacy domain: privacy
keywords: [retention-policy, allowed-tables, addallowedtable, reten-pol-allowed-tables, log-table-growth, mandatory-minimum-retention, install-upgrade-codeunit] keywords: [retention-policy, allowed-tables, addallowedtable, reten-pol-allowed-tables, onrefreshallowedtables, append-only-table, log-table-growth, deleteall, mandatory-minimum-retention, install-upgrade-codeunit]
technologies: [al] technologies: [al]
countries: [w1] countries: [w1]
application-area: [all] application-area: [all]
@ -15,7 +15,7 @@ The retention policy engine only ever deletes from tables that appear in its all
## Best Practice ## Best Practice
Register every table the extension owns that accumulates rows over time: activity and audit logs, integration and API request logs, archived documents. Call a shared routine from both the install codeunit (`OnInstallAppPerCompany`) and an upgrade codeunit (`OnUpgradePerCompany`), because install code does not run when an existing installation moves to a new version — registration added only to install code never reaches tenants that already have the app. Guard the routine with `IsAllowedTable` and an upgrade tag so repeated runs are idempotent. Pass `MandatoryMinRetenDays` when the data must survive a minimum period for audit or support reasons; the platform then rejects any shorter period an administrator configures. When only a subset of rows should ever expire, build the filter with `AddTableFilterToJsonArray` and pass it to the `AddAllowedTable` overload that takes a `JsonArray` — a filter added as locked cannot be removed later by the administrator. Register every table the extension owns that accumulates rows over time: activity and audit logs, integration and API request logs, archived documents. Call a shared routine from both the install codeunit (`OnInstallAppPerCompany`) and an upgrade codeunit (`OnUpgradePerCompany`), because install code does not run when an existing installation moves to a new version — registration added only to install code never reaches tenants that already have the app. Guard the routine with `IsAllowedTable` and an upgrade tag so repeated runs are idempotent, but keep a force path past the tag: the **Retention Policies** pages raise `Reten. Pol. Allowed Tables.OnRefreshAllowedTables`, and the platform's own installers (System Application, Base Application, Shopify) subscribe to it and re-run registration with `ForceUpdate`. A routine that exits whenever the tag is set cannot take part in that refresh, so the upgrade tag should gate one-time setup only, not re-registration. Pass `MandatoryMinRetenDays` when the data must survive a minimum period for audit or support reasons; the platform then rejects any shorter period an administrator configures. When only a subset of rows should ever expire, build the filter with `AddTableFilterToJsonArray` and pass it to the `AddAllowedTable` overload that takes a `JsonArray` — a filter added as locked cannot be removed later by the administrator.
Registering a table only makes it eligible; the policy itself is a separate concern, covered by [`ship-a-default-retention-policy-setup.md`](ship-a-default-retention-policy-setup.md). Registering a table only makes it eligible; the policy itself is a separate concern, covered by [`ship-a-default-retention-policy-setup.md`](ship-a-default-retention-policy-setup.md).
@ -23,11 +23,11 @@ See sample: [`register-owned-log-tables-for-retention-policies.good.al`](registe
## Anti Pattern ## Anti Pattern
An extension-owned log table with no `AddAllowedTable` call anywhere in the app, cleaned instead by hand-rolled code — a job queue codeunit or scheduled task running `DeleteAll` against a hard-coded date window. The deletion happens outside the **Retention Policy Log**, the administrator has no page on which to lengthen, shorten, or disable it, and the table is invisible during a data-retention review. The same defect in slower form is registration performed only in the install codeunit: new tenants are covered, every existing tenant stays unregistered after the upgrade. An extension-owned table that only grows — the app inserts into it but never deletes from it — with no `AddAllowedTable` call anywhere in the app. The name is not a reliable signal: an "Incoming Data" buffer-history table grows the same way an "Activity Log" does, and such tables have reached hundreds of gigabytes on customer tenants. A variant is a table cleaned by hand-rolled code — a job queue codeunit or scheduled task running `DeleteAll` against a hard-coded date window: the deletion happens outside the **Retention Policy Log**, the administrator has no page on which to lengthen, shorten, or disable it, and the table is invisible during a data-retention review. The same defect in slower form is registration performed only in the install codeunit: new tenants are covered, every existing tenant stays unregistered after the upgrade.
See sample: [`register-owned-log-tables-for-retention-policies.bad.al`](register-owned-log-tables-for-retention-policies.bad.al). See sample: [`register-owned-log-tables-for-retention-policies.bad.al`](register-owned-log-tables-for-retention-policies.bad.al).
## References ## References
- [Clean up data with retention policies](https://learn.microsoft.com/en-us/dynamics365/business-central/admin-data-retention-policies), section *Include your extension in a retention policy*. - [Clean up data with retention policies](https://learn.microsoft.com/en-us/dynamics365/business-central/admin-data-retention-policies), section *Include your extension in a retention policy*.
- [`RetenPolAllowedTables.Codeunit.al`](https://github.com/microsoft/BCApps/blob/main/src/System%20Application/App/Retention%20Policy/src/Retention%20Policy%20Allowed%20Tables/RetenPolAllowedTables.Codeunit.al) in microsoft/BCApps — the `AddAllowedTable` overloads, `IsAllowedTable`, and `AddTableFilterToJsonArray`. - [`RetenPolAllowedTables.Codeunit.al`](https://github.com/microsoft/BCApps/blob/main/src/System%20Application/App/Retention%20Policy/src/Retention%20Policy%20Allowed%20Tables/RetenPolAllowedTables.Codeunit.al) in microsoft/BCApps — the `AddAllowedTable` overloads, `IsAllowedTable`, `AddTableFilterToJsonArray`, and `OnRefreshAllowedTables`.

View file

@ -2,12 +2,10 @@ codeunit 50564 "Contoso Reten. Pol. Default"
{ {
Access = Internal; Access = Internal;
var
SixMonthsTok: Label 'Six Months', MaxLength = 20;
procedure CreateDefaultPolicy() procedure CreateDefaultPolicy()
var var
RetentionPolicySetup: Record "Retention Policy Setup"; RetentionPolicySetup: Record "Retention Policy Setup";
RetentionPolicySetupMgt: Codeunit "Retention Policy Setup";
UpgradeTag: Codeunit "Upgrade Tag"; UpgradeTag: Codeunit "Upgrade Tag";
begin begin
// Created once per company: an administrator who deletes the policy // Created once per company: an administrator who deletes the policy
@ -18,7 +16,9 @@ codeunit 50564 "Contoso Reten. Pol. Default"
if not RetentionPolicySetup.Get(Database::"Contoso Activity Log") then begin if not RetentionPolicySetup.Get(Database::"Contoso Activity Log") then begin
RetentionPolicySetup.Validate("Table Id", Database::"Contoso Activity Log"); RetentionPolicySetup.Validate("Table Id", Database::"Contoso Activity Log");
RetentionPolicySetup.Validate("Apply to all records", true); RetentionPolicySetup.Validate("Apply to all records", true);
RetentionPolicySetup.Validate("Retention Period", SixMonthRetentionPeriod()); RetentionPolicySetup.Validate(
"Retention Period",
RetentionPolicySetupMgt.FindOrCreateRetentionPeriod("Retention Period Enum"::"6 Months"));
RetentionPolicySetup.Validate(Enabled, false); // the administrator opts in to deletion RetentionPolicySetup.Validate(Enabled, false); // the administrator opts in to deletion
RetentionPolicySetup.Insert(true); RetentionPolicySetup.Insert(true);
end; end;
@ -26,21 +26,6 @@ codeunit 50564 "Contoso Reten. Pol. Default"
UpgradeTag.SetUpgradeTag(DefaultPolicyTag()); UpgradeTag.SetUpgradeTag(DefaultPolicyTag());
end; end;
local procedure SixMonthRetentionPeriod(): Code[20]
var
RetentionPeriod: Record "Retention Period";
begin
RetentionPeriod.SetRange("Retention Period", RetentionPeriod."Retention Period"::"6 Months");
if RetentionPeriod.FindFirst() then
exit(RetentionPeriod.Code);
RetentionPeriod.Code := CopyStr(UpperCase(SixMonthsTok), 1, MaxStrLen(RetentionPeriod.Code));
RetentionPeriod.Description := SixMonthsTok;
RetentionPeriod.Validate("Retention Period", RetentionPeriod."Retention Period"::"6 Months");
RetentionPeriod.Insert(true);
exit(RetentionPeriod.Code);
end;
local procedure DefaultPolicyTag(): Code[250] local procedure DefaultPolicyTag(): Code[250]
begin begin
exit('Contoso-ActivityLogDefaultPolicy-20260910'); exit('Contoso-ActivityLogDefaultPolicy-20260910');

View file

@ -1,7 +1,7 @@
--- ---
bc-version: [all] bc-version: [17..]
domain: privacy domain: privacy
keywords: [retention-policy-setup, retention-period, default-policy, unbounded-table-growth, opt-in-deletion, upgrade-tag] keywords: [retention-policy, retention-policy-setup, addallowedtable, findorcreateretentionperiod, retention-period, default-policy, unbounded-table-growth, opt-in-deletion, upgrade-tag]
technologies: [al] technologies: [al]
countries: [w1] countries: [w1]
application-area: [all] application-area: [all]
@ -15,7 +15,7 @@ application-area: [all]
## Best Practice ## Best Practice
In the same install and upgrade routine that registers the table (see [`register-owned-log-tables-for-retention-policies.md`](register-owned-log-tables-for-retention-policies.md)), create the `Retention Policy Setup` record: reuse an existing `Retention Period` whose `"Retention Period"` enum value matches the period you want and create one only when none exists, then `Validate` `"Table Id"`, `"Apply to all records"` and `"Retention Period"` before inserting. Gate the creation on an upgrade tag so it happens once per company rather than on every upgrade. Default to inserting with `Enabled` set to false: pre-creating the line puts a reviewed, sensible period in front of the administrator while leaving the decision to delete tenant data with them. Shipping the policy enabled is defensible for rows that are purely diagnostic and documented as transient — state that choice, and the default period, in the app's onboarding material either way. In the same install and upgrade routine that registers the table (see [`register-owned-log-tables-for-retention-policies.md`](register-owned-log-tables-for-retention-policies.md)), create the `Retention Policy Setup` record: get the period code from `Codeunit "Retention Policy Setup".FindOrCreateRetentionPeriod`, which reuses an existing `Retention Period` with the requested enum value and otherwise creates one without colliding on an existing code (a hand-written lookup-then-insert fails when a period with the chosen code already exists for a different value), then `Validate` `"Table Id"`, `"Apply to all records"` and `"Retention Period"` before inserting. Gate the creation on an upgrade tag so it happens once per company rather than on every upgrade. Default to inserting with `Enabled` set to false: pre-creating the line puts a reviewed, sensible period in front of the administrator while leaving the decision to delete tenant data with them. Shipping the policy enabled is defensible for rows that are purely diagnostic and documented as transient — state that choice, and the default period, in the app's onboarding material either way.
See sample: [`ship-a-default-retention-policy-setup.good.al`](ship-a-default-retention-policy-setup.good.al). See sample: [`ship-a-default-retention-policy-setup.good.al`](ship-a-default-retention-policy-setup.good.al).

View file

@ -37,11 +37,17 @@ Discard files that are not applicable. Retain conditionally applicable files (an
Narrow the relevant files to the subset that applies to the changes under review. Exclude test codeunits, test libraries, test helper code, files under test/Test/Tests paths, and objects with `Subtype = Test`; test data is synthetic and does not ship to customers. For each relevant file, compute overlap against: Narrow the relevant files to the subset that applies to the changes under review. Exclude test codeunits, test libraries, test helper code, files under test/Test/Tests paths, and objects with `Subtype = Test`; test data is synthetic and does not ship to customers. For each relevant file, compute overlap against:
- The changed AL object names and types — especially tables and tableextensions (for `DataClassification` on fields), codeunits that call `Error`, `Session.LogMessage`, or `FeatureTelemetry`, codeunits performing outgoing HTTP requests with customer data, migration codeunits, and objects reading or writing `IsolatedStorage`. - The changed AL object names and types — especially tables and tableextensions (for `DataClassification` on fields), codeunits that call `Error`, `Session.LogMessage`, or `FeatureTelemetry`, codeunits performing outgoing HTTP requests with customer data, migration codeunits, objects reading or writing `IsolatedStorage`, and install/upgrade codeunits or new tables relevant to retention policies.
- The changed procedures and triggers, weighted toward those that call `Error`, construct `ErrorInfo`, call `Session.LogMessage`, `StrSubstNo`, `GetLastErrorText`/`GetLastErrorCallStack`, `FeatureTelemetry.LogUsage`/`LogUptake`/`LogError`, `HttpClient.Post`/`Get`, `IsolatedStorage.Set`/`SetEncrypted`/`Get`, or `PrivacyNotice.GetPrivacyNoticeApprovalState`. - The changed procedures and triggers, weighted toward those that call `Error`, construct `ErrorInfo`, call `Session.LogMessage`, `StrSubstNo`, `GetLastErrorText`/`GetLastErrorCallStack`, `FeatureTelemetry.LogUsage`/`LogUptake`/`LogError`, `HttpClient.Post`/`Get`, `IsolatedStorage.Set`/`SetEncrypted`/`Get`, or `PrivacyNotice.GetPrivacyNoticeApprovalState`.
- Tokens extracted from the diff that relate to privacy (`DataClassification`, `CustomerContent`, `EndUserIdentifiableInformation`, `EndUserPseudonymousIdentifiers`, `SystemMetadata`, `ToBeClassified`, `PrivacyNotice`, `ErrorInfo`, `GetLastErrorText`, `GetLastErrorCallStack`, `TelemetryScope`, `FeatureTelemetry`, `CustomDimensions`, `LogUsage`, `LogUptake`, `LogError`, `ErrorText`, `ErrorCallStack`, `alErrorText`, `alErrorCallStack`, `HybridSL`, `HybridGP`, `HybridBC`). - Tokens extracted from the diff that relate to privacy (`DataClassification`, `CustomerContent`, `EndUserIdentifiableInformation`, `EndUserPseudonymousIdentifiers`, `SystemMetadata`, `ToBeClassified`, `PrivacyNotice`, `ErrorInfo`, `GetLastErrorText`, `GetLastErrorCallStack`, `TelemetryScope`, `FeatureTelemetry`, `CustomDimensions`, `LogUsage`, `LogUptake`, `LogError`, `ErrorText`, `ErrorCallStack`, `alErrorText`, `alErrorCallStack`, `HybridSL`, `HybridGP`, `HybridBC`).
- Treat `ErrorInfo.Message`, `ErrorInfo.DataClassification`, `ErrorInfo.ErrorType`, and `ErrorInfo.DetailedMessage` as qualified member signals: accept a call or assignment only when symbol resolution proves that its receiver expression or variable has type `ErrorInfo`. Normalize those accesses to `errorinfo-message`, `errorinfo-dataclassification`, `errorinfo-errortype`, and `errorinfo-detailedmessage` retrieval tokens. Bare `Message` or `DataClassification` tokens MUST NOT trigger this article; do not emit the qualified tokens for `Message(...)` dialog calls, table or table-field `DataClassification` properties, or similarly named members on other types. Resolve the receiver's declaration from the containing object when it is outside the changed hunk. - Treat `ErrorInfo.Message`, `ErrorInfo.DataClassification`, `ErrorInfo.ErrorType`, and `ErrorInfo.DetailedMessage` as qualified member signals: accept a call or assignment only when symbol resolution proves that its receiver expression or variable has type `ErrorInfo`. Normalize those accesses to `errorinfo-message`, `errorinfo-dataclassification`, `errorinfo-errortype`, and `errorinfo-detailedmessage` retrieval tokens. Bare `Message` or `DataClassification` tokens MUST NOT trigger this article; do not emit the qualified tokens for `Message(...)` dialog calls, table or table-field `DataClassification` properties, or similarly named members on other types. Resolve the receiver's declaration from the containing object when it is outside the changed hunk.
- Worklist ErrorInfo privacy guidance only from those typed `ErrorInfo` member tokens or from construction of an `ErrorInfo` value. For every `FeatureTelemetry.LogError`, inspect the dedicated error text and call-stack arguments in addition to explicit custom dimensions. - Worklist ErrorInfo privacy guidance only from those typed `ErrorInfo` member tokens or from construction of an `ErrorInfo` value. For every `FeatureTelemetry.LogError`, inspect the dedicated error text and call-stack arguments in addition to explicit custom dimensions.
- Retention-policy signals. Emit `addallowedtable` for any call to `Codeunit "Reten. Pol. Allowed Tables".AddAllowedTable`, `retention-policy-setup` for any use of `Record "Retention Policy Setup"`, and `retention-policy` for either. Emit `append-only-table` for an extension-owned table (never a tableextension) that the diff adds, or into which the diff adds a new `Insert` path, when the table also passes this bounded check: search the app's `.al` files for the table's object name once each for `AddAllowedTable`, `Delete`, and `DeleteAll`, and emit the token only when none of the three matches. This is a text search per candidate table — do not read or resolve the rest of the app. Emit `deleteall` when the diff adds a `DeleteAll` on an extension-owned table filtered by a date or datetime field inside a job queue codeunit or other scheduled cleanup and the table has no `AddAllowedTable` call.
Route retention-policy guidance deterministically:
- `register-owned-log-tables-for-retention-policies.md` is worklisted by `append-only-table` or `deleteall`, or when an `AddAllowedTable` routine is reached only from an install codeunit, or is guarded by an upgrade tag with no `OnRefreshAllowedTables` subscriber that bypasses the tag. Do not use the table's name as the trigger: an append-only table is a candidate whatever it is called ("Incoming Data" as much as "Activity Log"). A name ending in `Log`, `Entry`, `Archive`, `History`, or `Buffer`, an `AutoIncrement` integer primary key, or inserts from event subscribers, job queue codeunits, or API/web-service handlers raise confidence to `medium`; without any of these the finding stays `low`. Findings from `append-only-table` are advisory (`minor`) because table volume cannot be observed from source.
- `ship-a-default-retention-policy-setup.md` is worklisted by `addallowedtable` when the same install or upgrade routine, or the codeunit that contains it, never references `Record "Retention Policy Setup"`. It is also worklisted by `retention-policy-setup` when the diff inserts a setup with `Enabled` validated to true, or builds a `Retention Period` record by hand instead of calling `FindOrCreateRetentionPeriod`.
A file enters the candidate worklist when its `keywords` intersect the extracted tokens or its topic (derived from the index entry's `path`, `title`, and `description`) matches a changed object type. Apply the topic-specific gates above after this overlap check; in particular, bare `Message` and `DataClassification` tokens cannot admit ErrorInfo guidance. Read an article's full file — its `## Best Practice` / `## Anti Pattern` bodies — only after it makes the worklist; candidate selection uses the index alone. A file enters the candidate worklist when its `keywords` intersect the extracted tokens or its topic (derived from the index entry's `path`, `title`, and `description`) matches a changed object type. Apply the topic-specific gates above after this overlap check; in particular, bare `Message` and `DataClassification` tokens cannot admit ErrorInfo guidance. Read an article's full file — its `## Best Practice` / `## Anti Pattern` bodies — only after it makes the worklist; candidate selection uses the index alone.