Address second review round on retention policy knowledge

- Scope both articles to bc-version [22..]: FindOrCreateRetentionPeriod
  first appears in the 21.1 System Application and OnRefreshAllowedTables
  in 22.
- Reframe the default-setup article as optional guidance; registration
  without a setup is valid. The anti-pattern and review routing now cover
  only false claims that registration alone cleans up data.
- Make all four samples self-contained: declare Contoso Activity Log in
  each, add the Retention Policy Setup permission, and guard the default
  setup on IsAllowedTable.
- Let evaluation overrides list additionalArticles and register both
  retention pairs as extra privacy cases (38 cases, existing IDs unchanged).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Jeremy Vyska 2026-09-30 15:46:32 +02:00
parent 4bef582ffe
commit 07a171386d
10 changed files with 177 additions and 30 deletions

View file

@ -2,7 +2,7 @@
The evaluation is convention-driven. The harness discovers every `<layer>/skills/review/al-<domain>-review.md` leaf across the enabled `microsoft`, `community`, and `custom` layers. Duplicate domains resolve with `custom > community > microsoft` precedence. For each selected leaf, the harness finds paired knowledge across the same layers, applies the same precedence to duplicate article slugs, selects the first article (by filename) with both `.bad.al` and `.good.al` companions, and derives the expected positive and clean control automatically. Adding a conforming leaf requires no scoring-contract edit. The evaluation is convention-driven. The harness discovers every `<layer>/skills/review/al-<domain>-review.md` leaf across the enabled `microsoft`, `community`, and `custom` layers. Duplicate domains resolve with `custom > community > microsoft` precedence. For each selected leaf, the harness finds paired knowledge across the same layers, applies the same precedence to duplicate article slugs, selects the first article (by filename) with both `.bad.al` and `.good.al` companions, and derives the expected positive and clean control automatically. Adding a conforming leaf requires no scoring-contract edit.
`review-fixtures.json` contains only global thresholds and optional exceptional overrides. An override may select a different article or add context when the generic convention cannot express a scenario. It should remain empty in the normal case. `review-fixtures.json` contains only global thresholds and optional exceptional overrides. An override may select a different article, add context, or list `additionalArticles` whose sample pairs become extra positive and clean cases for that domain, when the generic convention cannot express a scenario. It should remain empty in the normal case.
Model-facing preparation hashes case IDs, neutralizes `Good`/`Bad` object-name tokens, and removes full-line sample comments so neither the article slug, domain, nor expected outcome reveals the answer. Model-facing preparation hashes case IDs, neutralizes `Good`/`Bad` object-name tokens, and removes full-line sample comments so neither the article slug, domain, nor expected outcome reveals the answer.

View file

@ -23,7 +23,11 @@
"article": "use-isempty-for-existence-check" "article": "use-isempty-for-existence-check"
}, },
"privacy": { "privacy": {
"article": "no-pii-in-telemetry-message-string" "article": "no-pii-in-telemetry-message-string",
"additionalArticles": [
"register-owned-log-tables-for-retention-policies",
"ship-a-default-retention-policy-setup"
]
}, },
"style": { "style": {
"article": "label-comment-explains-placeholders" "article": "label-comment-explains-placeholders"

View file

@ -1,3 +1,19 @@
table 50567 "Contoso Activity Log"
{
DataClassification = SystemMetadata;
fields
{
field(1; "Entry No."; Integer) { AutoIncrement = true; }
field(2; "Activity"; Text[250]) { }
}
keys
{
key(PK; "Entry No.") { Clustered = true; }
}
}
codeunit 50563 "Contoso Activity Log Cleanup" codeunit 50563 "Contoso Activity Log Cleanup"
{ {
Access = Internal; Access = Internal;

View file

@ -1,3 +1,19 @@
table 50566 "Contoso Activity Log"
{
DataClassification = SystemMetadata;
fields
{
field(1; "Entry No."; Integer) { AutoIncrement = true; }
field(2; "Activity"; Text[250]) { }
}
keys
{
key(PK; "Entry No.") { Clustered = true; }
}
}
codeunit 50560 "Contoso Reten. Pol. Setup" codeunit 50560 "Contoso Reten. Pol. Setup"
{ {
Access = Internal; Access = Internal;

View file

@ -1,5 +1,5 @@
--- ---
bc-version: [17..] bc-version: [22..]
domain: privacy domain: privacy
keywords: [retention-policy, allowed-tables, addallowedtable, reten-pol-allowed-tables, onrefreshallowedtables, append-only-table, log-table-growth, deleteall, 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]

View file

@ -1,18 +1,82 @@
table 50569 "Contoso Activity Log"
{
DataClassification = SystemMetadata;
fields
{
field(1; "Entry No."; Integer) { AutoIncrement = true; }
field(2; "Activity"; Text[250]) { }
}
keys
{
key(PK; "Entry No.") { Clustered = true; }
}
}
page 50570 "Contoso Activity Log"
{
PageType = List;
ApplicationArea = All;
UsageCategory = Lists;
SourceTable = "Contoso Activity Log";
Editable = false;
// Registration only makes the table selectable on the Retention Policies
// page. No Retention Policy Setup exists and nothing is deleted, yet the
// page tells the administrator that cleanup is running.
AboutTitle = 'About the activity log';
AboutText = 'Entries older than six months are deleted automatically, so the log never needs manual cleanup.';
layout
{
area(Content)
{
repeater(Entries)
{
field("Entry No."; Rec."Entry No.") { ToolTip = 'Specifies the entry number.'; }
field(Activity; Rec.Activity) { ToolTip = 'Specifies the logged activity.'; }
}
}
}
}
codeunit 50565 "Contoso Reten. Pol. Register" codeunit 50565 "Contoso Reten. Pol. Register"
{ {
Subtype = Install;
Access = Internal; Access = Internal;
// The table becomes selectable on the Retention Policies page and nothing procedure AddAllowedTables()
// else happens: no Retention Policy Setup record is created, so no period
// is proposed and no data is ever deleted. The log table keeps growing
// until someone notices the tenant's storage consumption.
trigger OnInstallAppPerCompany()
var var
ContosoActivityLog: Record "Contoso Activity Log"; ContosoActivityLog: Record "Contoso Activity Log";
RetenPolAllowedTables: Codeunit "Reten. Pol. Allowed Tables"; RetenPolAllowedTables: Codeunit "Reten. Pol. Allowed Tables";
begin begin
RetenPolAllowedTables.AddAllowedTable( if not RetenPolAllowedTables.IsAllowedTable(Database::"Contoso Activity Log") then
Database::"Contoso Activity Log", ContosoActivityLog.FieldNo(SystemCreatedAt)); RetenPolAllowedTables.AddAllowedTable(
Database::"Contoso Activity Log", ContosoActivityLog.FieldNo(SystemCreatedAt));
end;
}
codeunit 50571 "Contoso Reten. Pol. Install"
{
Subtype = Install;
Access = Internal;
trigger OnInstallAppPerCompany()
var
ContosoRetenPolRegister: Codeunit "Contoso Reten. Pol. Register";
begin
ContosoRetenPolRegister.AddAllowedTables();
end;
}
codeunit 50572 "Contoso Reten. Pol. Upgrade"
{
Subtype = Upgrade;
Access = Internal;
trigger OnUpgradePerCompany()
var
ContosoRetenPolRegister: Codeunit "Contoso Reten. Pol. Register";
begin
ContosoRetenPolRegister.AddAllowedTables();
end; end;
} }

View file

@ -1,13 +1,35 @@
table 50568 "Contoso Activity Log"
{
DataClassification = SystemMetadata;
fields
{
field(1; "Entry No."; Integer) { AutoIncrement = true; }
field(2; "Activity"; Text[250]) { }
}
keys
{
key(PK; "Entry No.") { Clustered = true; }
}
}
codeunit 50564 "Contoso Reten. Pol. Default" codeunit 50564 "Contoso Reten. Pol. Default"
{ {
Access = Internal; Access = Internal;
Permissions = tabledata "Retention Policy Setup" = ri;
procedure CreateDefaultPolicy() procedure CreateDefaultPolicy()
var var
RetentionPolicySetup: Record "Retention Policy Setup"; RetentionPolicySetup: Record "Retention Policy Setup";
RetentionPolicySetupMgt: Codeunit "Retention Policy Setup"; RetentionPolicySetupMgt: Codeunit "Retention Policy Setup";
RetenPolAllowedTables: Codeunit "Reten. Pol. Allowed Tables";
UpgradeTag: Codeunit "Upgrade Tag"; UpgradeTag: Codeunit "Upgrade Tag";
begin begin
// A setup can only be created for a table that is already registered.
if not RetenPolAllowedTables.IsAllowedTable(Database::"Contoso Activity Log") then
exit;
// Created once per company: an administrator who deletes the policy // Created once per company: an administrator who deletes the policy
// does not get it back on the next upgrade. // does not get it back on the next upgrade.
if UpgradeTag.HasUpgradeTag(DefaultPolicyTag()) then if UpgradeTag.HasUpgradeTag(DefaultPolicyTag()) then

View file

@ -1,5 +1,5 @@
--- ---
bc-version: [17..] bc-version: [22..]
domain: privacy domain: privacy
keywords: [retention-policy, retention-policy-setup, addallowedtable, findorcreateretentionperiod, 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]
@ -7,21 +7,21 @@ countries: [w1]
application-area: [all] application-area: [all]
--- ---
# Registering a table is not a retention policy — ship a default setup # Registering a table does not delete anything — consider shipping a default setup
## Description ## Description
`AddAllowedTable` only makes a table selectable on the **Retention Policies** page. Nothing is deleted until a `Retention Policy Setup` record exists for that table, names a `Retention Period`, and is enabled. An extension that registers its log tables and stops there ships unbounded growth as its default behaviour: the administrator has to discover the page, know which of the extension's tables are safe to trim, and pick a period the extension's own author never documented. Microsoft's `Codeunit 3907 "Retention Policy Installer"` shows the intended shape — it registers `Retention Policy Log Entry`, then creates a setup record with a six-month period on first install, inserted disabled, guarded by an upgrade tag so a policy the administrator later deleted is not recreated on the next upgrade. `AddAllowedTable` only makes a table selectable on the **Retention Policies** page. Nothing is deleted until a `Retention Policy Setup` record exists for that table, names a `Retention Period`, and is enabled. Registration alone is a valid pattern — several Microsoft apps register tables and leave the policy entirely to the administrator — but it means the table keeps growing until someone discovers the page, works out which of the extension's tables are safe to trim, and picks a period. Shipping a default setup is optional; where the extension's author knows a sensible period, it removes that discovery step. Microsoft's `Codeunit 3907 "Retention Policy Installer"` shows the shape — it registers `Retention Policy Log Entry`, then creates a setup record with a six-month period on first install, inserted disabled, guarded by an upgrade tag so a policy the administrator later deleted is not recreated on the next upgrade.
## 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: 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. When you ship a default, do it 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)), and 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. The codeunit that inserts the record needs `tabledata "Retention Policy Setup" = ri`. 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).
## Anti Pattern ## Anti Pattern
Install code that calls `AddAllowedTable` and treats the table as covered by retention policies. The signal is an install or upgrade routine that touches `Codeunit "Reten. Pol. Allowed Tables"` but never `Record "Retention Policy Setup"`; the symptom is a support case where the extension's log table holds millions of rows on a tenant whose **Retention Policies** page has no line for it. The mirror-image defect is inserting the setup with `Enabled` set to true and no documentation, so the app begins deleting tenant data on a schedule nobody approved. Registering a table and then claiming, in a message, notification, label, teaching tip, or setup text, that its data is now cleaned up automatically. Registration only makes the table selectable; without an enabled `Retention Policy Setup` nothing is deleted, so the administrator is told a cleanup is running when none is, and the table grows unnoticed. Registration without a default setup is not itself a defect.
See sample: [`ship-a-default-retention-policy-setup.bad.al`](ship-a-default-retention-policy-setup.bad.al). See sample: [`ship-a-default-retention-policy-setup.bad.al`](ship-a-default-retention-policy-setup.bad.al).

View file

@ -47,7 +47,7 @@ Narrow the relevant files to the subset that applies to the changes under review
Route retention-policy guidance deterministically: 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. - `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`. - `ship-a-default-retention-policy-setup.md` is worklisted by `addallowedtable` or `retention-policy-setup`, but registration without a `Retention Policy Setup` is valid and MUST NOT produce a finding. Report only a false claim: a `Message`, notification, label, page `AboutText`/`InstructionalText`, or setup text in the diff stating that the registered table's data is now cleaned up or deleted automatically when no enabled `Retention Policy Setup` is created for it.
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.

View file

@ -4,7 +4,7 @@
.DESCRIPTION .DESCRIPTION
CI uses the static validation path to prove every registered AL review leaf CI uses the static validation path to prove every registered AL review leaf
has one positive and one clean control, every fixture/reference exists, and has at least one positive and one clean control, every fixture/reference exists, and
the manifest remains internally consistent. the manifest remains internally consistent.
For an actual model run, -PrepareDirectory copies inputs to neutral names and For an actual model run, -PrepareDirectory copies inputs to neutral names and
@ -226,24 +226,49 @@ foreach ($domain in $leafDomains) {
continue continue
} }
$articlePath = [string]$selectedArticle.ArticlePath # The primary article keeps the domain-level case IDs; additionalArticles add
$sampleDirectory = (Split-Path -Parent $articlePath).Replace('\', '/') # further paired cases keyed by slug so existing case hashes stay stable.
$selections = [System.Collections.Generic.List[object]]::new()
$selections.Add([pscustomobject]@{ Article = $selectedArticle; IdPrefix = $domain }) | Out-Null
if ($override -and ($override.PSObject.Properties.Name -contains 'additionalArticles')) {
foreach ($additionalName in @($override.additionalArticles)) {
$additionalName = [string]$additionalName
if ($additionalName.EndsWith('.md')) {
$additionalName = [System.IO.Path]::GetFileNameWithoutExtension($additionalName)
}
if (@($selections | Where-Object { $_.Article.BaseName -eq $additionalName }).Count) {
$problems.Add("${domain}: additional article is already selected: $additionalName.md") | Out-Null
continue
}
$additionalArticle = $articles | Where-Object BaseName -eq $additionalName | Select-Object -First 1
if (-not $additionalArticle) {
$problems.Add("${domain}: additional article does not exist or lacks .good.al and .bad.al companion samples: $additionalName.md") | Out-Null
continue
}
$selections.Add([pscustomobject]@{ Article = $additionalArticle; IdPrefix = "$domain-$additionalName" }) | Out-Null
}
}
$context = if ($override -and ($override.PSObject.Properties.Name -contains 'context')) { $context = if ($override -and ($override.PSObject.Properties.Name -contains 'context')) {
[string]$override.context [string]$override.context
} else { } else {
$null $null
} }
foreach ($kind in 'bad', 'good') { foreach ($selection in $selections) {
$case = [pscustomobject]@{ $articlePath = [string]$selection.Article.ArticlePath
id = "$domain-$kind" $sampleDirectory = (Split-Path -Parent $articlePath).Replace('\', '/')
domain = $domain foreach ($kind in 'bad', 'good') {
input = "$sampleDirectory/$($selectedArticle.BaseName).$kind.al" $case = [pscustomobject]@{
expected = if ($kind -eq 'bad') { @($articlePath) } else { @() } id = "$($selection.IdPrefix)-$kind"
domain = $domain
input = "$sampleDirectory/$($selection.Article.BaseName).$kind.al"
expected = if ($kind -eq 'bad') { @($articlePath) } else { @() }
}
if ($context) {
$case | Add-Member -NotePropertyName context -NotePropertyValue $context
}
$caseList.Add($case) | Out-Null
} }
if ($context) {
$case | Add-Member -NotePropertyName context -NotePropertyValue $context
}
$caseList.Add($case) | Out-Null
} }
} }
$cases = @($caseList) $cases = @($caseList)