Merge branch 'main' of https://github.com/demiliani/BCQuality into appsource

This commit is contained in:
demiliani 2026-09-07 14:28:12 +02:00
commit 2ec109e534
204 changed files with 3165 additions and 404 deletions

View file

@ -0,0 +1,11 @@
permissionset 50100 "SALES REVIEW AGENT"
{
Assignable = true;
Permissions =
tabledata "Sales Header" = RIM,
tabledata Customer = R,
tabledata User = RIMD,
tabledata "Access Control" = RIMD,
page "Sales Order" = X,
page "User Card" = X;
}

View file

@ -0,0 +1,8 @@
permissionset 50100 "SALES REVIEW AGENT"
{
Assignable = true;
Permissions =
tabledata "Sales Header" = RIM,
tabledata Customer = R,
page "Sales Order" = X;
}

View file

@ -0,0 +1,30 @@
---
bc-version: [27..]
domain: agents
keywords: [permissions, assigner, intersection, user-card, least-privilege]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Agent permissions intersect the assigner's; agents cannot configure users
## Description
An agent is a user, but it cannot configure users or other agents, and it cannot open sensitive pages such as user cards or permission-set assignment. Effective rights are the intersection of the assigning user's permissions and the agent's permission sets. Granting the agent a wide set does not bypass the assigner's limits, and a wide assigner still cannot give the agent user-admin powers the platform forbids.
## Best Practice
Document that intersection. Give the agent only the table and page rights its tasks need. Do not add user-setup or permission-assignment pages to the agent profile or permission sets; those operations will fail by design.
See sample: `agent-permissions-intersect-with-assigner.good.al`.
## Anti Pattern
Permission sets or profiles that include User card, Permission Set Assignment, or agent-admin pages, or comments that the agent runs as SUPER regardless of who assigned it. Detection signal: default access controls or profile including user-administration objects.
See sample: `agent-permissions-intersect-with-assigner.bad.al`.
## See also
`get-default-access-controls-least-privilege.md` covers the permission sets assigned when an agent instance is created.

View file

@ -0,0 +1,8 @@
codeunit 50100 "Sales Review Agent Factory"
{
procedure GetDefaultProfile(var TempAllProfile: Record "All Profile" temporary)
begin
TempAllProfile."Profile ID" := 'BUSINESS MANAGER';
TempAllProfile.Insert();
end;
}

View file

@ -0,0 +1,26 @@
profile "SALES REVIEW AGENT"
{
Caption = 'Sales Review Agent';
Description = 'Restricted UI for the Sales Review Agent.';
RoleCenter = "Order Processor Role Center";
Customizations = "Sales Review Agent Sales Ord.";
}
pagecustomization "Sales Review Agent Sales Ord." customizes "Sales Order"
{
layout
{
modify("Payment Terms Code")
{
Visible = false;
}
}
actions
{
modify(Post)
{
Visible = false;
}
}
}

View file

@ -0,0 +1,30 @@
---
bc-version: [27..]
domain: agents
keywords: [profile, page-customization, hidden-actions, tooltip, role-center]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Give the agent a dedicated profile that hides unrelated UI
## Description
The agent only sees what its profile shows. Extra actions, views, and Role Center tiles become extra tools and extra tokens. Accuracy and cost both get worse as the UI widens. A human Order Processor profile is usually far too broad. Tooltips on the remaining actions are part of the tool description.
## Best Practice
Ship an agent-specific profile and page customizations: hide unrelated actions, keep descriptive tooltips, add Role Center links to the few pages the agent should open. Prefer fewer navigation hops.
See sample: `agent-profile-narrows-visible-ui.good.al`.
## Anti Pattern
Assigning `BUSINESS MANAGER` or `ORDER PROCESSOR` as `GetDefaultProfile` so the agent can do anything. Detection signal: default profile equal to a full-user role with no agent page customizations.
See sample: `agent-profile-narrows-visible-ui.bad.al`.
## See also
`get-default-profile-lives-in-the-app.md` covers packaging and assigning the profile that this rule narrows.

View file

@ -0,0 +1,19 @@
page 50100 "Sales Review Agent Setup"
{
PageType = Card;
Caption = 'Set up Sales Review Agent';
SourceTable = "Sales Review Agent Setup";
layout
{
area(Content)
{
field(ReviewThreshold; Rec."Review Threshold")
{
ApplicationArea = All;
Caption = 'Review Threshold';
ToolTip = 'Specifies the threshold used when the agent requests a review.';
}
}
}
}

View file

@ -0,0 +1,30 @@
page 50100 "Sales Review Agent Setup"
{
PageType = ConfigurationDialog;
Caption = 'Set up Sales Review Agent';
SourceTable = "Sales Review Agent Setup";
SourceTableTemporary = true;
Extensible = false;
layout
{
area(Content)
{
part(AgentSetupPart; "Agent Setup Part")
{
ApplicationArea = All;
UpdatePropagation = Both;
}
group(AdditionalConfiguration)
{
Caption = 'Additional Configuration';
field(ReviewThreshold; Rec."Review Threshold")
{
ApplicationArea = All;
Caption = 'Review Threshold';
ToolTip = 'Specifies the threshold used when the agent requests a review.';
}
}
}
}
}

View file

@ -0,0 +1,26 @@
---
bc-version: [27..]
domain: agents
keywords: [configurationdialog, agent-setup-part, setup-page, pagetype, system-actions]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Agent setup pages use ConfigurationDialog and the Agent Setup Part
## Description
Instance setup is not a Card or StandardDialog. The toolkit expects `PageType = ConfigurationDialog` so OK and Cancel are system actions, plus the built-in `Agent Setup Part` for name, display name, state, and access. A Card with custom fields only drops those shared controls and the AI-use notices the part carries.
## Best Practice
Declare `PageType = ConfigurationDialog`, host `part(...; "Agent Setup Part")`, and put agent-specific fields in another group. Keep system OK/Cancel. Use a temporary source record and defer persistence until Update, as described in `agent-setup-source-table-is-temporary.md`. Following Microsoft's agent setup samples, set `Extensible = false`.
See sample: `agent-setup-page-is-configuration-dialog.good.al`.
## Anti Pattern
A Card or StandardDialog setup page with no `Agent Setup Part`. Detection signal: setup page ID from `IAgentFactory` / `IAgentMetadata` whose page is not `ConfigurationDialog` or has no `Agent Setup Part`.
See sample: `agent-setup-page-is-configuration-dialog.bad.al`.

View file

@ -0,0 +1,29 @@
page 50100 "Sales Review Agent Setup"
{
PageType = ConfigurationDialog;
SourceTable = "Sales Review Agent Setup";
layout
{
area(Content)
{
field(ReviewThreshold; Rec."Review Threshold")
{
ApplicationArea = All;
Caption = 'Review Threshold';
ToolTip = 'Specifies the threshold used when the agent requests a review.';
trigger OnValidate()
begin
Rec.Modify(true);
end;
}
}
}
trigger OnOpenPage()
begin
if Rec.IsEmpty() then
Rec.Insert(true);
end;
}

View file

@ -0,0 +1,68 @@
page 50100 "Sales Review Agent Setup"
{
PageType = ConfigurationDialog;
SourceTable = "Sales Review Agent Setup";
SourceTableTemporary = true;
Extensible = false;
layout
{
area(Content)
{
part(AgentSetupPart; "Agent Setup Part")
{
ApplicationArea = All;
UpdatePropagation = Both;
}
group(AdditionalConfiguration)
{
Caption = 'Additional Configuration';
field(ReviewThreshold; Rec."Review Threshold")
{
ApplicationArea = All;
Caption = 'Review Threshold';
ToolTip = 'Specifies the threshold used when the agent requests a review.';
}
}
}
}
trigger OnOpenPage()
var
SalesReviewAgentSetup: Record "Sales Review Agent Setup";
begin
if IsNullGuid(Rec."User Security ID") then
exit;
if SalesReviewAgentSetup.Get(Rec."User Security ID") then
Rec := SalesReviewAgentSetup;
end;
trigger OnQueryClosePage(CloseAction: Action): Boolean
var
AgentSetup: Codeunit "Agent Setup";
AgentSetupBuffer: Record "Agent Setup Buffer";
begin
if CloseAction = CloseAction::Cancel then
exit(true);
CurrPage.AgentSetupPart.Page.GetAgentSetupBuffer(AgentSetupBuffer);
if AgentSetup.GetChangesMade(AgentSetupBuffer) then
Rec."User Security ID" := AgentSetup.SaveChanges(AgentSetupBuffer);
if IsNullGuid(Rec."User Security ID") then
exit(true);
SaveCustomProperties();
exit(true);
end;
local procedure SaveCustomProperties()
var
SalesReviewAgentSetup: Record "Sales Review Agent Setup";
begin
if not SalesReviewAgentSetup.Get(Rec."User Security ID") then begin
SalesReviewAgentSetup.Init();
SalesReviewAgentSetup."User Security ID" := Rec."User Security ID";
SalesReviewAgentSetup.Insert(true);
end;
SalesReviewAgentSetup."Review Threshold" := Rec."Review Threshold";
SalesReviewAgentSetup.Modify(true);
end;
}

View file

@ -0,0 +1,30 @@
---
bc-version: [27..]
domain: agents
keywords: [sourcetabletemporary, configurationdialog, savechanges, cancel, draft]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Keep the agent setup page source temporary until Update
## Description
ConfigurationDialog setup is a draft: the user can Cancel without writing. That only works if `SourceTableTemporary = true` and custom fields stay in memory until Update. Writing the real table in OnValidate or OnOpenPage commits a partial agent when the dialog errors or is cancelled.
## Best Practice
Mark the page `SourceTableTemporary = true`. Copy into the temp record on open. Persist the Agent Setup buffer and custom fields only from the close path when the action is not Cancel, using `Agent Setup.GetChangesMade` / `SaveChanges`.
See sample: `agent-setup-source-table-is-temporary.good.al`.
## Anti Pattern
A non-temporary source table, or `Insert`/`Modify` on the persisted setup row from field OnValidate. Detection signal: agent `ConfigurationDialog` without `SourceTableTemporary = true`, or database writes before Update.
See sample: `agent-setup-source-table-is-temporary.bad.al`.
## See also
`agent-setup-page-is-configuration-dialog.md` defines the setup page shape that uses this draft lifecycle.

View file

@ -0,0 +1,24 @@
table 50100 "Sales Review Agent Setup"
{
DataClassification = CustomerContent;
fields
{
field(1; "Primary Key"; Code[10])
{
Caption = 'Primary Key';
}
field(10; "Review Threshold"; Decimal)
{
Caption = 'Review Threshold';
}
}
keys
{
key(PK; "Primary Key")
{
Clustered = true;
}
}
}

View file

@ -0,0 +1,25 @@
table 50100 "Sales Review Agent Setup"
{
DataClassification = CustomerContent;
fields
{
field(1; "User Security ID"; Guid)
{
Caption = 'User Security ID';
DataClassification = EndUserPseudonymousIdentifiers;
}
field(10; "Review Threshold"; Decimal)
{
Caption = 'Review Threshold';
}
}
keys
{
key(PK; "User Security ID")
{
Clustered = true;
}
}
}

View file

@ -0,0 +1,26 @@
---
bc-version: [27..]
domain: agents
keywords: [user-security-id, setup-table, primary-key, agent-instance, guid]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Agent setup tables are keyed by User Security ID
## Description
Each agent instance is a user. Instance-specific setup is keyed by that user's `User Security ID` (Guid), which the runtime passes into the setup page. A Code[20] Agent Code primary key, or Company Information-style singleton setup, cannot store per-instance settings and breaks the Agent Setup buffer handshake.
## Best Practice
Give the setup table a Guid field `User Security ID` as the clustered primary key. Other settings are attributes of that key. When the page opens, `Get` or insert by the Guid the Agent Setup part already holds.
See sample: `agent-setup-table-keyed-by-user-security-id.good.al`.
## Anti Pattern
A setup table keyed by Code, Integer, or with no Guid user key, then mapping one row to every instance. Detection signal: source table of the agent setup page whose primary key is not `User Security ID`.
See sample: `agent-setup-table-keyed-by-user-security-id.bad.al`.

View file

@ -0,0 +1,8 @@
codeunit 50100 "Sales Review Agent Task"
{
procedure AnalyzeAgentTaskMessage(AgentTaskMessage: Record "Agent Task Message"; var Annotations: Record "Agent Annotation")
begin
// No validation. Combined with SetRequiresReview(false) this auto-runs
// untrusted input. Warnings are the only way to force a review later.
end;
}

View file

@ -0,0 +1,41 @@
codeunit 50100 "Sales Review Agent Task"
{
procedure AnalyzeAgentTaskMessage(AgentTaskMessage: Record "Agent Task Message"; var Annotations: Record "Agent Annotation")
var
AgentMessage: Codeunit "Agent Message";
EmptyMessageMsg: Label 'Message is empty.';
EmptyMessageDetailsTxt: Label 'Provide a sales order task before running the agent.';
NotRelevantMsg: Label 'Message is not a sales order task.';
NotRelevantDetailsTxt: Label 'Provide a message related to sales order review.';
MessageText: Text;
begin
if AgentTaskMessage.Type = AgentTaskMessage.Type::Output then begin
AgentMessage.UpdateText(AgentTaskMessage, AgentMessage.GetText(AgentTaskMessage) + #13#10 + #13#10 + 'Written with the help of AI');
exit;
end;
MessageText := AgentMessage.GetText(AgentTaskMessage);
if MessageText = '' then begin
Clear(Annotations);
Annotations.Code := 'MESSAGE001';
Annotations.Severity := Annotations.Severity::Error;
Annotations.Message := EmptyMessageMsg;
Annotations.Details := EmptyMessageDetailsTxt;
Annotations.Insert();
exit;
end;
if not IsRelevant(MessageText) then begin
Clear(Annotations);
Annotations.Code := 'RELEVANCE001';
Annotations.Severity := Annotations.Severity::Warning;
Annotations.Message := NotRelevantMsg;
Annotations.Details := NotRelevantDetailsTxt;
Annotations.Insert();
end;
end;
local procedure IsRelevant(MessageText: Text): Boolean
begin
exit(StrPos(LowerCase(MessageText), 'sales order') > 0);
end;
}

View file

@ -0,0 +1,26 @@
---
bc-version: [27..]
domain: agents
keywords: [analyzeagenttaskmessage, agent-annotation, error, warning, setrequiresreview]
technologies: [al]
countries: [w1]
application-area: [all]
---
# AnalyzeAgentTaskMessage: Error stops the task; Warning still requires review
## Description
`IAgentTaskExecution.AnalyzeAgentTaskMessage` runs on inbound and outbound messages. An Error annotation stops processing. A Warning annotation requests user intervention. If analysis returns Warning, the platform still requires approval even when the incoming message used `SetRequiresReview(false)`. Output text can be rewritten here (signature, redaction).
## Best Practice
Validate inbound payloads in analysis: Error when the task must not run; Warning when a human must confirm. For outbound messages, adjust text in this method rather than in a later subscriber. Do not rely on skip-review to bypass warnings.
See sample: `analyze-message-error-stops-warning-forces-review.good.al`.
## Anti Pattern
Ignoring analysis entirely, or emitting Warning while documenting that `SetRequiresReview(false)` means unattended run. Detection signal: empty `AnalyzeAgentTaskMessage` plus skip-review on external input.
See sample: `analyze-message-error-stops-warning-forces-review.bad.al`.

View file

@ -0,0 +1,9 @@
codeunit 50101 "Sales Review Agent Events"
{
[EventSubscriber(ObjectType::Table, Database::"Sales Header", OnAfterInsertEvent, '', false, false)]
local procedure OnAfterInsertSalesHeader(var Rec: Record "Sales Header")
begin
// Runs for every user session, not only the agent.
Message('Keep going, agent.');
end;
}

View file

@ -0,0 +1,34 @@
codeunit 50101 "Sales Review Agent Subscribers"
{
Access = Internal;
EventSubscriberInstance = Manual;
SingleInstance = true;
[EventSubscriber(ObjectType::Table, Database::"Sales Header", OnAfterInsertEvent, '', false, false)]
local procedure OnAfterInsertSalesHeader(var Rec: Record "Sales Header")
begin
Message('Keep going, agent.');
end;
}
codeunit 50102 "Agent Session Events"
{
Access = Internal;
SingleInstance = true;
InherentEntitlements = X;
InherentPermissions = X;
var
GlobalAgentSubscribers: Codeunit "Sales Review Agent Subscribers";
[EventSubscriber(ObjectType::Codeunit, Codeunit::"System Initialization", OnAfterInitialization, '', false, false)]
local procedure RegisterSubscribersOnAfterInitialization()
var
AgentSession: Codeunit "Agent Session";
AgentMetadataProvider: Enum "Agent Metadata Provider";
begin
if not AgentSession.IsAgentSession(AgentMetadataProvider) then
exit;
if BindSubscription(GlobalAgentSubscribers) then;
end;
}

View file

@ -0,0 +1,26 @@
---
bc-version: [27..]
domain: agents
keywords: [agent-session, isagentsession, bindsubscription, system-initialization, singleinstance]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Bind extra agent subscribers only inside an agent session
## Description
Page-filter tweaks, extra validation, and prompt dialogs for the agent should not run for every user. `Agent Session.IsAgentSession` distinguishes agent UI sessions. Binding those subscribers on `System Initialization` only when the session is an agent session avoids global subscriber cost. Models register `SingleInstance` table subscribers unconditionally.
## Best Practice
On `OnAfterInitialization`, exit unless `Agent Session.IsAgentSession`. Then `BindSubscription` a single-instance codeunit that holds the current task id. Keep those subscribers internal.
See sample: `bind-agent-subscribers-only-in-agent-session.good.al`.
## Anti Pattern
Event subscribers on `Sales Header` OnAfterInsert that always `Message` the agent, with no `IsAgentSession` guard. Detection signal: agent-only behaviour in a static subscriber that is not bind-gated.
See sample: `bind-agent-subscribers-only-in-agent-session.bad.al`.

View file

@ -0,0 +1,10 @@
codeunit 50110 "Other App Agent Hook"
{
procedure RenameForeignAgent(AgentUserSecurityId: Guid)
var
Agent: Codeunit Agent;
begin
// Fails at runtime when the instance was defined in another app.
Agent.SetDisplayName(AgentUserSecurityId, 'Updated Name');
end;
}

View file

@ -0,0 +1,21 @@
codeunit 50110 "Sales Review Agent API"
{
Access = Public;
procedure SetDisplayName(AgentUserSecurityId: Guid; NewDisplayName: Text[80])
var
Agent: Codeunit Agent;
begin
Agent.SetDisplayName(AgentUserSecurityId, NewDisplayName);
end;
procedure SetActiveState(AgentUserSecurityId: Guid; ActivateAgent: Boolean)
var
Agent: Codeunit Agent;
begin
if ActivateAgent then
Agent.Activate(AgentUserSecurityId)
else
Agent.Deactivate(AgentUserSecurityId);
end;
}

View file

@ -0,0 +1,26 @@
---
bc-version: [27..]
domain: agents
keywords: [cross-app, public-api, agent-create, isolation, access-public]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Other apps cannot call the toolkit APIs on your agent; publish your own API
## Description
For isolation, `Agent`, `Agent Task Builder`, and related toolkit codeunits error when the target instance belongs to another app. There is no supported way to pass another extension's metadata provider into `SetInstructions` or `Create`. Partners who need to enqueue work must call a public API you own.
## Best Practice
Expose a public codeunit in the agent app (`Access = Public`) whose procedures take `User Security ID` and forward to `Agent` / `Agent Task Builder`. Document that surface as the integration contract. Keep toolkit calls inside that app.
See sample: `cross-app-agent-calls-need-your-public-api.good.al`.
## Anti Pattern
From app B, calling `Agent.SetDisplayName` or `Agent.Create` with app A's metadata provider. Detection signal: toolkit agent APIs used with an `Agent Metadata Provider` value not declared in the same app.
See sample: `cross-app-agent-calls-need-your-public-api.bad.al`.

View file

@ -0,0 +1,19 @@
codeunit 50100 "Sales Review Agent Install"
{
Subtype = Install;
trigger OnInstallAppPerCompany()
var
Agent: Codeunit Agent;
TempAgentAccessControl: Record "Agent Access Control" temporary;
AgentUserSecurityId: Guid;
begin
// Create requires an interactive session. Install is not one.
AgentUserSecurityId := Agent.Create(
Enum::"Agent Metadata Provider"::"Sales Review Agent",
'SALESREVIEW',
'Sales Review Agent',
TempAgentAccessControl);
Agent.Activate(AgentUserSecurityId);
end;
}

View file

@ -0,0 +1,44 @@
page 50100 "Sales Review Agent Setup"
{
PageType = ConfigurationDialog;
ApplicationArea = All;
SourceTable = "Sales Review Agent Setup";
SourceTableTemporary = true;
Extensible = false;
layout
{
area(Content)
{
part(AgentSetupPart; "Agent Setup Part")
{
ApplicationArea = All;
UpdatePropagation = Both;
}
}
}
trigger OnQueryClosePage(CloseAction: Action): Boolean
var
Agent: Codeunit Agent;
AgentSetup: Codeunit "Agent Setup";
TempAgentSetupBuffer: Record "Agent Setup Buffer" temporary;
AgentUserSecurityId: Guid;
begin
if CloseAction = CloseAction::Cancel then
exit(true);
CurrPage.AgentSetupPart.Page.GetAgentSetupBuffer(TempAgentSetupBuffer);
AgentUserSecurityId := AgentSetup.SaveChanges(TempAgentSetupBuffer);
Agent.SetInstructions(AgentUserSecurityId, GetInstructions());
Agent.Activate(AgentUserSecurityId);
exit(true);
end;
local procedure GetInstructions() Instructions: SecretText
var
InstructionsNameTxt: Label 'Instructions.txt', Locked = true;
begin
Instructions := NavApp.GetResourceAsText(InstructionsNameTxt);
end;
}

View file

@ -0,0 +1,26 @@
---
bc-version: [27..]
domain: agents
keywords: [agent-create, install, upgrade, job-queue, interactive-session, background]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Do not create agent instances from install, upgrade, or background sessions
## Description
`Agent.Create` requires an interactive user session. The platform blocks creation from install codeunits, upgrade codeunits, and background sessions (job queue, scheduled tasks). Packaging an agent in an app does not mean spinning up instances at install. Models still call `Create` from `OnInstallAppPerCompany` to activate the agent.
## Best Practice
Create instances from a setup page, a wizard, or another UI-driven path after the user is in a client session. Apply instructions and `Activate` there. For existing companies after an upgrade, document that an admin must open setup; do not create from the upgrade codeunit.
See sample: `do-not-create-agents-in-install-upgrade-or-background.good.al`.
## Anti Pattern
`Agent.Create` inside `OnInstallAppPerCompany`, `OnUpgradePerCompany`, or a job-queue codeunit. The call fails at runtime even if it compiles. Detection signal: `Agent.Create` in `Subtype = Install`, `Subtype = Upgrade`, or a non-UI session.
See sample: `do-not-create-agents-in-install-upgrade-or-background.bad.al`.

View file

@ -0,0 +1,14 @@
codeunit 50100 "Sales Review Agent Factory"
{
procedure GetDefaultAccessControls(var TempAccessControlBuffer: Record "Access Control Buffer" temporary)
var
BaseApplicationAppIdTok: Label '437dbf0e-84ff-417a-965d-ed2bb9650972', Locked = true;
begin
Clear(TempAccessControlBuffer);
TempAccessControlBuffer."Company Name" := CopyStr(CompanyName(), 1, MaxStrLen(TempAccessControlBuffer."Company Name"));
TempAccessControlBuffer.Scope := TempAccessControlBuffer.Scope::System;
TempAccessControlBuffer."App ID" := BaseApplicationAppIdTok;
TempAccessControlBuffer."Role ID" := 'D365 BUS FULL ACCESS';
TempAccessControlBuffer.Insert();
end;
}

View file

@ -0,0 +1,25 @@
permissionset 50100 "SALES REVIEW AGENT"
{
Assignable = true;
Caption = 'Sales Review Agent';
Permissions =
tabledata "Sales Header" = R,
tabledata "Sales Line" = R;
}
codeunit 50100 "Sales Review Agent Factory"
{
procedure GetDefaultAccessControls(var TempAccessControlBuffer: Record "Access Control Buffer" temporary)
var
CurrentModuleInfo: ModuleInfo;
RoleIdTok: Label 'SALES REVIEW AGENT', Locked = true;
begin
NavApp.GetCurrentModuleInfo(CurrentModuleInfo);
Clear(TempAccessControlBuffer);
TempAccessControlBuffer."Company Name" := CopyStr(CompanyName(), 1, MaxStrLen(TempAccessControlBuffer."Company Name"));
TempAccessControlBuffer.Scope := TempAccessControlBuffer.Scope::System;
TempAccessControlBuffer."App ID" := CurrentModuleInfo.Id;
TempAccessControlBuffer."Role ID" := RoleIdTok;
TempAccessControlBuffer.Insert();
end;
}

View file

@ -0,0 +1,30 @@
---
bc-version: [27..]
domain: agents
keywords: [getdefaultaccesscontrols, access-control-buffer, permissionset, least-privilege, iagentfactory]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Default agent permission sets must exist in AL and stay least privilege
## Description
`IAgentFactory.GetDefaultAccessControls` fills a temporary `Access Control Buffer` used when an instance is created. Permission sets that exist only as user-created sets in a sandbox are missing in the next environment. Granting `D365 BUS FULL ACCESS` or SUPER gives the agent a user-sized blast radius. Effective rights are still the intersection with the assigning user's permissions.
## Best Practice
Insert only the permission sets the agent needs. For an AL `permissionset` object, use `Scope::System` and the ID of the app that defines it. Recreate permission sets that exist only as user-defined configuration in Business Central as AL objects first. Prefer a dedicated permission set over a full-user role.
See sample: `get-default-access-controls-least-privilege.good.al`.
## Anti Pattern
Empty `GetDefaultAccessControls`, or inserting `SUPER` / `D365 BUS FULL ACCESS` because it made the demo work. Detection signal: Role ID on the default buffer that is a full-user role, or a set that is not in the app.
See sample: `get-default-access-controls-least-privilege.bad.al`.
## See also
`agent-permissions-intersect-with-assigner.md` explains the platform limits that still apply after default access controls are assigned.

View file

@ -0,0 +1,9 @@
codeunit 50100 "Sales Review Agent Factory"
{
procedure GetDefaultProfile(var TempAllProfile: Record "All Profile" temporary)
begin
// Profile exists only as a user personalization in the design sandbox.
TempAllProfile."Profile ID" := 'SALES REVIEW SANDBOX';
TempAllProfile.Insert();
end;
}

View file

@ -0,0 +1,20 @@
profile "SALES REVIEW AGENT"
{
Caption = 'Sales Review Agent';
Description = 'UI surface for the Sales Review Agent.';
RoleCenter = "Order Processor Role Center";
Customizations = "Sales Review Agent Sales Ord.";
}
codeunit 50100 "Sales Review Agent Factory"
{
procedure GetDefaultProfile(var TempAllProfile: Record "All Profile" temporary)
var
Agent: Codeunit Agent;
CurrentModuleInfo: ModuleInfo;
DefaultProfileTok: Label 'SALES REVIEW AGENT', Locked = true;
begin
NavApp.GetCurrentModuleInfo(CurrentModuleInfo);
Agent.PopulateDefaultProfile(DefaultProfileTok, CurrentModuleInfo.Id, TempAllProfile);
end;
}

View file

@ -0,0 +1,30 @@
---
bc-version: [27..]
domain: agents
keywords: [getdefaultprofile, profile, page-customization, populatedefaultprofile, role-center]
technologies: [al]
countries: [w1]
application-area: [all]
---
# The default agent profile must be an AL profile in the app
## Description
`IAgentFactory.GetDefaultProfile` assigns the Role Center and page customizations the agent UI-navigates. A profile built only in the client, or page personalization that was never exported, is absent after deploy. `Agent.PopulateDefaultProfile` still needs a profile ID that exists in the current module.
## Best Practice
Ship a `profile` object (and page customizations) in the app. In `GetDefaultProfile`, call `Agent.PopulateDefaultProfile` with that profile ID and `NavApp.GetCurrentModuleInfo`. Include UI-exported customizations as AL.
See sample: `get-default-profile-lives-in-the-app.good.al`.
## Anti Pattern
Setting `TempAllProfile."Profile ID"` to a client-only profile, or skipping `GetDefaultProfile`. Detection signal: factory default profile ID with no matching `profile` object in the app.
See sample: `get-default-profile-lives-in-the-app.bad.al`.
## See also
`agent-profile-narrows-visible-ui.md` explains which UI the app-owned profile should expose.

View file

@ -0,0 +1,9 @@
codeunit 50100 "Sales Review Agent Instr."
{
procedure GetInstructions() Instructions: SecretText
var
PromptLbl: Label 'Check customer credit for the given sales order. Document the result.', Locked = true;
begin
Instructions := PromptLbl;
end;
}

View file

@ -0,0 +1,17 @@
codeunit 50100 "Sales Review Agent Instr."
{
procedure GetInstructions() Instructions: SecretText
var
Builder: TextBuilder;
begin
Builder.AppendLine('# Responsibilities');
Builder.AppendLine('You validate sales orders against customer credit and hold status.');
Builder.AppendLine('# Guidelines');
Builder.AppendLine('Always request a review before posting or sending external mail.');
Builder.AppendLine('# Instructions');
Builder.AppendLine('1. Open the sales order named in the task.');
Builder.AppendLine('2. Check credit limit and overdue balance.');
Builder.AppendLine('3. Document the result on the order and request a review.');
Instructions := Builder.ToText();
end;
}

View file

@ -0,0 +1,30 @@
---
bc-version: [27..]
domain: agents
keywords: [instructions, responsibilities, guidelines, steps, setinstructions]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Instruction documents use responsibilities, guidelines, then ordered steps
## Description
The runtime treats instructions as the agent's standing prompt. A one-line goal produces inconsistent navigation. Microsoft's instruction framework is three layers: responsibilities (what the agent owns), guidelines (rules for every task), and instructions (ordered steps per task, with substeps). That structure is BC-specific, not generic prompt flavour.
## Best Practice
Store a document that states responsibilities, then non-negotiable guidelines (when to request a review, when not to post), then numbered steps for each task. Keep that text in the resource you pass to `SetInstructions`.
See sample: `instruction-structure-is-role-rules-steps.good.al`.
## Anti Pattern
A single sentence such as Check customer credit for the sales order. Detection signal: instruction resource or `SetInstructions` payload with no responsibilities / guidelines / steps sections.
See sample: `instruction-structure-is-role-rules-steps.bad.al`.
## See also
`instructions-describe-work-not-tool-ids.md` and `use-documented-instruction-keywords.md` define how to write the steps inside this structure.

View file

@ -0,0 +1,7 @@
codeunit 50100 "Sales Review Agent Instr."
{
procedure GetInstructions() Instructions: SecretText
begin
Instructions := 'Open page 42. Invoke action Post_Promoted. Use tool SalesOrder.CreditCheck_v3.';
end;
}

View file

@ -0,0 +1,12 @@
codeunit 50100 "Sales Review Agent Instr."
{
procedure GetInstructions() Instructions: SecretText
var
Builder: TextBuilder;
begin
Builder.AppendLine('Memorize the sales order number from the task.');
Builder.AppendLine('Set the order on hold when credit fails, with a reason.');
Builder.AppendLine('When credit passes, request a review before posting the order.');
Instructions := Builder.ToText();
end;
}

View file

@ -0,0 +1,30 @@
---
bc-version: [27..]
domain: agents
keywords: [instructions, tools, invoke-action, memorize, page-actions]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Instructions describe outcomes, not page action or tool names
## Description
Agent tools are the UI the profile exposes. Action names and tool ids change across pages and versions. Best-practice guidance is to say what to accomplish, not which tool to invoke. Page state is also not fully in history; values needed later must be memorized. Models paste Promoted action names into the prompt.
## Best Practice
Write steps as business outcomes (release the order, set the hold reason). Tell the agent to memorize identifiers it must reuse. Do not hard-code action captions or tool ids.
See sample: `instructions-describe-work-not-tool-ids.good.al`.
## Anti Pattern
Instructions that say invoke SalesOrder.Post_Promoted or use tool page-42-action-3. Detection signal: instruction text containing Promoted action names or tool identifiers.
See sample: `instructions-describe-work-not-tool-ids.bad.al`.
## See also
`instruction-structure-is-role-rules-steps.md` defines the containing document structure, and `use-documented-instruction-keywords.md` identifies runtime-recognized phrases.

View file

@ -0,0 +1,18 @@
codeunit 50100 "Sales Review Agent Create"
{
procedure CreateWithInstructions()
var
Agent: Codeunit Agent;
TempAgentAccessControl: Record "Agent Access Control" temporary;
AgentUserSecurityId: Guid;
InstructionsNameTxt: Label 'Instructions.txt', Locked = true;
begin
AgentUserSecurityId := Agent.Create(
Enum::"Agent Metadata Provider"::"Sales Review Agent",
'SALESREVIEW',
'Sales Review Agent',
TempAgentAccessControl);
// Only new instances get the resource. Upgrades never re-apply it.
Agent.SetInstructions(AgentUserSecurityId, NavApp.GetResourceAsText(InstructionsNameTxt));
end;
}

View file

@ -0,0 +1,33 @@
codeunit 50100 "Sales Review Agent Upgrade"
{
Subtype = Upgrade;
trigger OnUpgradePerCompany()
var
Agent: Codeunit Agent;
UpgradeTag: Codeunit "Upgrade Tag";
Instructions: SecretText;
AgentUserSecurityIds: List of [Guid];
AgentUserSecurityId: Guid;
TagTxt: Label 'SALESREVIEW-INSTR-2.0.0', Locked = true;
InstructionsNameTxt: Label 'Instructions.txt', Locked = true;
begin
if UpgradeTag.HasUpgradeTag(TagTxt) then
exit;
Instructions := NavApp.GetResourceAsText(InstructionsNameTxt);
AgentUserSecurityIds := GetExistingAgentUserIds();
foreach AgentUserSecurityId in AgentUserSecurityIds do
Agent.SetInstructions(AgentUserSecurityId, Instructions);
UpgradeTag.SetUpgradeTag(TagTxt);
end;
local procedure GetExistingAgentUserIds() AgentUserSecurityIds: List of [Guid]
var
SalesReviewAgentSetup: Record "Sales Review Agent Setup";
begin
if SalesReviewAgentSetup.FindSet() then
repeat
AgentUserSecurityIds.Add(SalesReviewAgentSetup."User Security ID");
until SalesReviewAgentSetup.Next() = 0;
end;
}

View file

@ -0,0 +1,26 @@
---
bc-version: [27..]
domain: agents
keywords: [upgrade, setinstructions, navapp-getresourceastext, existing-instances, upgrade-tag]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Reapply resource instructions to existing agent instances on upgrade
## Description
Static instructions stored as an app resource are copied onto an instance only when you call `SetInstructions`. Shipping a new `Instructions.txt` in version 2.0 does not update agents created under 1.0. Models change the resource and assume running instances pick it up.
## Best Practice
In the upgrade codeunit, find existing instances of your metadata provider and call `SetInstructions` again with `NavApp.GetResourceAsText`. Guard with an upgrade tag so the rewrite runs once per version that changes the file.
See sample: `reapply-resource-instructions-on-upgrade.good.al`.
## Anti Pattern
Editing only the resource file, or calling `SetInstructions` solely from the first-time setup path. Detection signal: instruction resource in `resourceFolders` with no upgrade procedure that re-applies it.
See sample: `reapply-resource-instructions-on-upgrade.bad.al`.

View file

@ -0,0 +1,21 @@
enumextension 50100 "Sales Review Agent Metadata" extends "Agent Metadata Provider"
{
value(50100; "Sales Review Agent")
{
Caption = 'Sales Review Agent';
Implementation = IAgentFactory = "Sales Review Agent Factory",
IAgentMetadata = "Sales Review Agent Metadata",
IAgentTaskExecution = "Sales Review Agent Task";
}
}
codeunit 50101 "Sales Review Agent Install"
{
Subtype = Install;
Access = Internal;
trigger OnInstallAppPerDatabase()
begin
// Agent type exists, but no Copilot Capability value and no RegisterCapability.
end;
}

View file

@ -0,0 +1,26 @@
enumextension 50101 "Sales Review Agent Copilot" extends "Copilot Capability"
{
value(50101; "Sales Review Agent")
{
Caption = 'Sales Review Agent';
}
}
codeunit 50101 "Sales Review Agent Install"
{
Subtype = Install;
Access = Internal;
trigger OnInstallAppPerDatabase()
var
CopilotCapability: Codeunit "Copilot Capability";
LearnMoreUrlTxt: Label 'https://example.com/sales-review-agent', Locked = true;
begin
if not CopilotCapability.IsCapabilityRegistered(Enum::"Copilot Capability"::"Sales Review Agent") then
CopilotCapability.RegisterCapability(
Enum::"Copilot Capability"::"Sales Review Agent",
Enum::"Copilot Availability"::Preview,
Enum::"Copilot Billing Type"::"Microsoft Billed",
LearnMoreUrlTxt);
end;
}

View file

@ -0,0 +1,30 @@
---
bc-version: [27..]
domain: agents
keywords: [copilot-capability, registercapability, install, feature-switch, enumextension]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Register a Copilot capability for the agent on install
## Description
Each agent type needs a `Copilot Capability` enum value that the factory links as the feature switch and billing surface. The capability is invisible on Copilot and agent capabilities until an install codeunit calls `RegisterCapability` when it is not already registered. Unique ordinals matter across installed apps. Models often extend `Agent Metadata Provider` and never register the capability.
## Best Practice
Extend `Copilot Capability` with a unique value. In `OnInstallAppPerDatabase`, call `Copilot Capability.IsCapabilityRegistered` and, if false, `RegisterCapability` with availability, billing type, and a learn-more URL. Point `IAgentFactory` at that capability.
See sample: `register-copilot-capability-for-the-agent.good.al`.
## Anti Pattern
Shipping the agent enum without a `Copilot Capability` value, or adding the enum but never calling `RegisterCapability`. Duplicate ordinals across extensions also collide. Detection signal: agent metadata provider with no matching capability registration in an install codeunit.
See sample: `register-copilot-capability-for-the-agent.bad.al`.
## See also
`wire-all-three-agent-interfaces.md` covers registration of the provider implementation that references this capability.

View file

@ -0,0 +1,19 @@
codeunit 50100 "Sales Review Agent Create"
{
procedure CreateWithInstructions()
var
Agent: Codeunit Agent;
TempAgentAccessControl: Record "Agent Access Control" temporary;
AgentUserSecurityId: Guid;
InstructionsLbl: Label 'You are a sales validation agent. Check credit.', Locked = true;
begin
AgentUserSecurityId := Agent.Create(
Enum::"Agent Metadata Provider"::"Sales Review Agent",
'SALESREVIEW',
'Sales Review Agent',
TempAgentAccessControl);
// Label/text is not SecretText and is type-wide, not per instance.
Agent.SetInstructions(AgentUserSecurityId, InstructionsLbl);
Agent.Activate(AgentUserSecurityId);
end;
}

View file

@ -0,0 +1,20 @@
codeunit 50100 "Sales Review Agent Create"
{
procedure CreateWithInstructions()
var
Agent: Codeunit Agent;
TempAgentAccessControl: Record "Agent Access Control" temporary;
AgentUserSecurityId: Guid;
Instructions: SecretText;
InstructionsNameTxt: Label 'Instructions.txt', Locked = true;
begin
AgentUserSecurityId := Agent.Create(
Enum::"Agent Metadata Provider"::"Sales Review Agent",
'SALESREVIEW',
'Sales Review Agent',
TempAgentAccessControl);
Instructions := NavApp.GetResourceAsText(InstructionsNameTxt);
Agent.SetInstructions(AgentUserSecurityId, Instructions);
Agent.Activate(AgentUserSecurityId);
end;
}

View file

@ -0,0 +1,26 @@
---
bc-version: [27..]
domain: agents
keywords: [setinstructions, secrettext, instructions, per-instance, resource]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Set agent instructions as SecretText on the instance
## Description
Instructions are instance data, not an enum caption. `Agent.SetInstructions` takes `SecretText` so the payload is not logged or copied as ordinary text. A Label or plaintext Text on the agent type is the wrong store: it leaks into telemetry-friendly strings and cannot vary per instance or company.
## Best Practice
Load instruction text from a resource or builder into a `SecretText` variable and call `Agent.SetInstructions(AgentUserSecurityId, Instructions)` after `Create`. Keep one instruction document per instance.
See sample: `set-instructions-as-secrettext.good.al`.
## Anti Pattern
Passing a `Label` or `Text` to `SetInstructions`, storing instructions in a setup Text field without wrapping as `SecretText`, or putting the prompt only in a code comment. Detection signal: `SetInstructions` with a non-`SecretText` argument, or no `SetInstructions` after `Create`.
See sample: `set-instructions-as-secrettext.bad.al`.

View file

@ -0,0 +1,36 @@
codeunit 50100 "Sales Review Agent Factory"
{
procedure ShowCanCreateAgent(): Boolean
begin
// Author intends this to forbid all creates. It only hides the UI tile.
exit(false);
end;
}
pageextension 50100 "Sales Order List Agent Create" extends "Sales Order List"
{
actions
{
addlast(Processing)
{
action(CreateAgent)
{
ApplicationArea = All;
Caption = 'Create review agent';
trigger OnAction()
var
Agent: Codeunit Agent;
TempAgentAccessControl: Record "Agent Access Control" temporary;
begin
// Still succeeds for any caller with permission to run this action.
Agent.Create(
Enum::"Agent Metadata Provider"::"Sales Review Agent",
'SALESREVIEW',
'Sales Review Agent',
TempAgentAccessControl);
end;
}
}
}
}

View file

@ -0,0 +1,25 @@
codeunit 50100 "Sales Review Agent Factory"
{
procedure ShowCanCreateAgent(): Boolean
var
AgentSystemPermissions: Codeunit "Agent System Permissions";
begin
// Hides the type from non-admins in the UI. Does not block Agent.Create.
exit(AgentSystemPermissions.CurrentUserHasCanManageAllAgentsPermission());
end;
procedure CreateIfAllowed()
var
Agent: Codeunit Agent;
AgentSystemPermissions: Codeunit "Agent System Permissions";
TempAgentAccessControl: Record "Agent Access Control" temporary;
begin
if not AgentSystemPermissions.CurrentUserHasCanManageAllAgentsPermission() then
Error('Only agent administrators can create this agent.');
Agent.Create(
Enum::"Agent Metadata Provider"::"Sales Review Agent",
'SALESREVIEW',
'Sales Review Agent',
TempAgentAccessControl);
end;
}

View file

@ -0,0 +1,26 @@
---
bc-version: [28..]
domain: agents
keywords: [showcancreateagent, agent-discovery, agent-create, administrator, agent-configuration-rights]
technologies: [al]
countries: [w1]
application-area: [all]
---
# ShowCanCreateAgent only hides UI create, not programmatic create
## Description
`IAgentFactory.ShowCanCreateAgent` controls whether the type appears in the in-client create UI. Returning false does not stop `Agent.Create` from AL. From 28.1, non-admins can discover extension agents unless this method (and agent configuration rights) restrict them. Models treat a false return as a hard create lock.
## Best Practice
Use `ShowCanCreateAgent` to decide discovery. If only agent administrators should see the type, return `Agent System Permissions.CurrentUserHasCanManageAllAgentsPermission`. Enforce extra policy inside your own create API. Never assume UI hiding blocks code.
See sample: `show-can-create-agent-does-not-block-code-create.good.al`.
## Anti Pattern
Returning `exit(false)` from `ShowCanCreateAgent` and then documenting that instances cannot be created, while page actions or other apps still call `Agent.Create`. Detection signal: `ShowCanCreateAgent` always false with no matching guard on programmatic create.
See sample: `show-can-create-agent-does-not-block-code-create.bad.al`.

View file

@ -0,0 +1,15 @@
codeunit 50100 "Sales Review Agent Tasks"
{
procedure EnqueueFromEmailBody(RawEmailBody: Text; AgentUserSecurityId: Guid)
var
AgentTaskBuilder: Codeunit "Agent Task Builder";
AgentTaskMessageBuilder: Codeunit "Agent Task Message Builder";
AgentTask: Record "Agent Task";
begin
AgentTaskMessageBuilder.Initialize('Internet', RawEmailBody)
.SetRequiresReview(false);
AgentTask := AgentTaskBuilder.Initialize(AgentUserSecurityId, 'Process inbound mail')
.AddTaskMessage(AgentTaskMessageBuilder)
.Create();
end;
}

View file

@ -0,0 +1,16 @@
codeunit 50100 "Sales Review Agent Tasks"
{
procedure EnqueueFromSalesOrder(SalesHeader: Record "Sales Header"; AgentUserSecurityId: Guid)
var
AgentTaskBuilder: Codeunit "Agent Task Builder";
AgentTaskMessageBuilder: Codeunit "Agent Task Message Builder";
AgentTask: Record "Agent Task";
begin
SalesHeader.TestField("No.");
AgentTaskMessageBuilder.Initialize('Sales Team', 'Review sales order ' + SalesHeader."No.")
.SetRequiresReview(false);
AgentTask := AgentTaskBuilder.Initialize(AgentUserSecurityId, 'Review Sales Order')
.AddTaskMessage(AgentTaskMessageBuilder)
.Create();
end;
}

View file

@ -0,0 +1,26 @@
---
bc-version: [28..]
domain: agents
keywords: [setrequiresreview, agent-task-message-builder, approval, trusted-input, skip-review]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Skip incoming message review only after the caller validated the payload
## Description
Incoming task messages default to requiring user approval before the agent runs. From 28.1, `Agent Task Message Builder.SetRequiresReview(false)` starts the agent immediately. That is safe only for inputs you already validated in AL (your page action, your posting subscriber). External email or partner payloads are not trusted by default. Analysis Warnings still force a review.
## Best Practice
Leave the default review-on for anything that originated outside your extension. Call `SetRequiresReview(false)` only on messages you constructed from already-authorized BC data.
See sample: `skip-incoming-review-only-for-trusted-input.good.al`.
## Anti Pattern
`SetRequiresReview(false)` on simulated email, incoming webhooks, or user-free text. Detection signal: `SetRequiresReview(false)` next to external content with no prior validation.
See sample: `skip-incoming-review-only-for-trusted-input.bad.al`.

View file

@ -0,0 +1,7 @@
codeunit 50100 "Sales Review Agent Instr."
{
procedure GetInstructions() Instructions: SecretText
begin
Instructions := 'When done, email the customer and remember the credit limit. Click Post_Promoted.';
end;
}

View file

@ -0,0 +1,13 @@
codeunit 50100 "Sales Review Agent Instr."
{
procedure GetInstructions() Instructions: SecretText
var
Builder: TextBuilder;
begin
Builder.AppendLine('When the sales order is ready, request a review before posting.');
Builder.AppendLine('If a field is missing, ask for assistance.');
Builder.AppendLine('Memorize the customer credit limit for later steps.');
Builder.AppendLine('When confirmed, write an email to the salesperson; outbound mail is reviewed.');
Instructions := Builder.ToText();
end;
}

View file

@ -0,0 +1,30 @@
---
bc-version: [27..]
domain: agents
keywords: [instruction-keywords, request-a-review, memorize, write-an-email, invoke-action]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Use the toolkit instruction keywords for review, mail, and memory
## Description
The agent runtime looks for specific phrases: ask for assistance, request a review, reply, write an email, memorize, `Set field`, use lookup, `Invoke action`. Ordinary English such as get a human to look or remember this is weaker. Outbound reply and email always require review; that is platform policy, not optional tone.
## Best Practice
In the instruction resource, use those keywords at the decision points: request a review before posting; write an email only after stating that outbound mail is reviewed; memorize values the later steps need. Pair `Reply` / `Write an email` with an explicit review sentence.
See sample: `use-documented-instruction-keywords.good.al`.
## Anti Pattern
Inventing tool-like verbs (call Copilot, click Post_Promoted) or omitting request a review before posting. Detection signal: instruction text that says email the customer with no review keyword.
See sample: `use-documented-instruction-keywords.bad.al`.
## See also
`instruction-structure-is-role-rules-steps.md` defines the containing document structure, while `instructions-describe-work-not-tool-ids.md` keeps outcomes independent of UI tool identifiers.

View file

@ -0,0 +1,9 @@
enumextension 50100 "Sales Review Agent Metadata" extends "Agent Metadata Provider"
{
value(50100; "Sales Review Agent")
{
Caption = 'Sales Review Agent';
// Only factory is bound. Metadata UI and task execution never resolve.
Implementation = IAgentFactory = "Sales Review Agent Factory";
}
}

View file

@ -0,0 +1,10 @@
enumextension 50100 "Sales Review Agent Metadata" extends "Agent Metadata Provider"
{
value(50100; "Sales Review Agent")
{
Caption = 'Sales Review Agent';
Implementation = IAgentFactory = "Sales Review Agent Factory",
IAgentMetadata = "Sales Review Agent Meta. Impl.",
IAgentTaskExecution = "Sales Review Agent Task";
}
}

View file

@ -0,0 +1,30 @@
---
bc-version: [27..]
domain: agents
keywords: [agent-metadata-provider, iagentfactory, iagentmetadata, iagenttaskexecution, enumextension, implementation]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Wire all three agent interfaces on the metadata provider
## Description
An AL agent type is registered by extending `Agent Metadata Provider`. The platform locates factory, metadata, and task-execution behaviour only through the `Implementation` property on that enum value. Omitting `IAgentFactory`, `IAgentMetadata`, or `IAgentTaskExecution` leaves create, UI identity, or task runs unbound. Models often ship a single codeunit and skip the enum wiring.
## Best Practice
On the enum value, set `Implementation` for all three interfaces, each pointing at a dedicated codeunit. Keep factory (create, defaults, first-time setup), metadata (setup page, summary, annotations), and task execution (message analysis, intervention suggestions) in separate objects.
See sample: `wire-all-three-agent-interfaces.good.al`.
## Anti Pattern
An `Agent Metadata Provider` value with no `Implementation`, only one interface mapped, or all three interfaces pointing at one catch-all codeunit that cannot satisfy the contracts. Detection signal: enumextension of `Agent Metadata Provider` whose value does not list `IAgentFactory`, `IAgentMetadata`, and `IAgentTaskExecution`.
See sample: `wire-all-three-agent-interfaces.bad.al`.
## See also
`register-copilot-capability-for-the-agent.md` covers the feature capability linked by the factory implementation.

View file

@ -1,48 +0,0 @@
table 50100 "Order Header"
{
fields
{
field(1; "Entry No."; Integer)
{
DataClassification = CustomerContent;
}
}
keys
{
key(PK; "Entry No.")
{
Clustered = true;
}
}
// No OnDelete. Deleting a header silently orphans every Order Line that
// belonged to it. Nothing errors, and no page shows the stranded rows.
}
table 50101 "Order Line"
{
fields
{
field(1; "Line No."; Integer)
{
DataClassification = CustomerContent;
}
field(2; "Header Entry No."; Integer)
{
DataClassification = CustomerContent;
// Reads like referential integrity. It is lookup and input validation
// only: it propagates a RENAME of the parent key, and cascades nothing
// on DELETE.
TableRelation = "Order Header"."Entry No.";
}
}
keys
{
key(PK; "Line No.")
{
Clustered = true;
}
}
}

View file

@ -1,55 +0,0 @@
table 50100 "Order Header"
{
// The owning table needs delete rights on what it owns. Granting D only on the
// header is a common miss and makes OnDelete fail for a non-SUPER user.
Permissions = tabledata "Order Line" = rd;
fields
{
field(1; "Entry No."; Integer)
{
DataClassification = CustomerContent;
}
}
keys
{
key(PK; "Entry No.")
{
Clustered = true;
}
}
trigger OnDelete()
var
OrderLine: Record "Order Line";
begin
OrderLine.SetRange("Header Entry No.", "Entry No.");
// Pass false only when Order Line has no OnDelete of its own.
OrderLine.DeleteAll(true);
end;
}
table 50101 "Order Line"
{
fields
{
field(1; "Line No."; Integer)
{
DataClassification = CustomerContent;
}
field(2; "Header Entry No."; Integer)
{
DataClassification = CustomerContent;
TableRelation = "Order Header"."Entry No.";
}
}
keys
{
key(PK; "Line No.")
{
Clustered = true;
}
}
}

View file

@ -1,36 +0,0 @@
---
bc-version: [all]
domain: data-modeling
keywords: [ondelete, cascade, table-relation, orphan-records, header-line, dependent-records, referential-integrity]
technologies: [al]
countries: [w1]
application-area: [all]
---
# A table that owns dependent records must delete them in `OnDelete`
## Description
`TableRelation` looks like referential integrity but only performs lookup and input validation. AL has **no cascading delete**: deleting a parent record leaves every dependent row untouched, and no error is raised.
What makes this specifically missable is an asymmetry. The platform *does* keep references correct on **rename** — renaming a record updates it in all other locations that declare a `TableRelation` to it, with no code. Delete has no equivalent. Same relation, same metadata, opposite behaviour. A developer who correctly learns that `TableRelation` "keeps references consistent" from the rename case, and generalises it to delete, ships orphans.
Orphaned rows are usually invisible, because a dependent table rarely has a page of its own. They inflate the table, break later reconciliation, and are re-encountered by duplicate checks when the parent key is reused.
This applies to internal, staging and `SystemMetadata` tables too. A table having no delete action in the UI today is not protection: a permission set that grants `D` on the table is evidence that deletion is anticipated.
See also `validate-table-relation-false-suppresses-rename-propagation.md` for the two preconditions on the rename half of this asymmetry.
## Best Practice
The owning table implements `OnDelete` and deletes its dependents there, filtered on the foreign key. Declare `Permissions = tabledata <dependent> = rd` on the owning table — granting delete rights only on the parent is a common miss that makes the trigger fail for a non-`SUPER` user. This mirrors the base application, where every header table deletes its own lines.
See sample: `owning-table-must-delete-dependents-in-ondelete.good.al`.
## Anti Pattern
A parent table with dependent rows and no `OnDelete` trigger, where the dependent's foreign-key field declares a `TableRelation` back to the parent. The relation reads as if it guarantees integrity; it does not.
Detection signal: a table declares `TableRelation` to table X, and table X has no `OnDelete` trigger. Whether a delete path currently exists in the UI is irrelevant to the finding.
See sample: `owning-table-must-delete-dependents-in-ondelete.bad.al`.

View file

@ -1,55 +0,0 @@
table 50123 "Transfer Source Bad"
{
fields
{
field(1; "Entry No."; Integer)
{
DataClassification = CustomerContent;
}
field(2; "Reference"; Code[20])
{
DataClassification = CustomerContent;
}
}
keys
{
key(PK; "Entry No.")
{
Clustered = true;
}
}
}
table 50124 "Transfer Target Bad"
{
fields
{
field(1; "Entry No."; Integer)
{
DataClassification = CustomerContent;
}
field(2; "Reference"; Integer)
{
DataClassification = CustomerContent;
}
}
keys
{
key(PK; "Entry No.")
{
Clustered = true;
}
}
}
codeunit 50492 "TransferFields Bad"
{
procedure CopyData(Source: Record "Transfer Source Bad"; var Target: Record "Transfer Target Bad")
begin
Target.TransferFields(Source, true, true);
end;
}

View file

@ -1,59 +0,0 @@
table 50121 "Transfer Source"
{
fields
{
field(1; "Entry No."; Integer)
{
DataClassification = CustomerContent;
}
field(2; "Reference"; Code[20])
{
DataClassification = CustomerContent;
}
}
keys
{
key(PK; "Entry No.")
{
Clustered = true;
}
}
}
table 50122 "Transfer Target"
{
fields
{
field(1; "Entry No."; Integer)
{
DataClassification = CustomerContent;
}
field(2; "Reference"; Integer)
{
DataClassification = CustomerContent;
}
}
keys
{
key(PK; "Entry No.")
{
Clustered = true;
}
}
}
codeunit 50491 "TransferFields Good"
{
procedure CopyData(Source: Record "Transfer Source"; var Target: Record "Transfer Target")
var
ConvertedReference: Integer;
begin
Target."Entry No." := Source."Entry No.";
Evaluate(ConvertedReference, Source."Reference");
Target.Validate("Reference", ConvertedReference);
end;
}

View file

@ -1,26 +0,0 @@
---
bc-version: [16..]
domain: data-modeling
keywords: [transferfields, skipfieldsnotmatchingtype, type-mismatch, field-mapping, data-transfer]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Do not use SkipFieldsNotMatchingType to hide required TransferFields mismatches
## Description
`Record.TransferFields` copies values between fields with matching field numbers. Without `SkipFieldsNotMatchingType` (or with it `false`), a type mismatch between two fields in the same extension raises a runtime error at the point of transfer. Setting `SkipFieldsNotMatchingType` to `true` removes that error: the field is skipped instead, and the rest of the transfer completes normally. The caller gets no indication that a field was not copied.
## Best Practice
Use `TransferFields(Source)` only when every field the destination requires, including primary key fields, is guaranteed to share a matching field number and type with the source; this form defaults `InitPrimaryKeyFields` to `true`. Fields with no matching field number, and fields whose types differ across extensions, are skipped regardless of `SkipFieldsNotMatchingType` — that parameter only governs same-extension type mismatches. If the destination depends on a field that falls into either case, map and validate it explicitly in code rather than relying on `TransferFields` to catch the gap. Use `SkipFieldsNotMatchingType = true` only when skipping same-extension type mismatches is an intentional, documented part of the transfer contract.
See sample: `transferfields-skip-type-mismatch-can-drop-data.good.al`.
## Anti Pattern
Using `TransferFields(Source, InitPrimaryKeyFields, true)` as a generic way to make two evolving table schemas transfer without errors, when the destination depends on every required source field being copied. A type change on either table can turn a previously transferred field into a silently skipped one without making the transfer itself fail.
See sample: `transferfields-skip-type-mismatch-can-drop-data.bad.al`.

View file

@ -1,35 +0,0 @@
table 50121 "Document Link"
{
fields
{
field(1; "Entry No."; Integer)
{
DataClassification = CustomerContent;
}
field(2; "Document No."; Code[20])
{
DataClassification = CustomerContent;
TableRelation = "Source Document"."No.";
// Added to silence a validation error while the row is staged, before
// the Source Document exists. The relation is still declared, so this
// reads as harmless — but it also switches OFF rename propagation.
// Renaming a Source Document now leaves this field on the old key,
// with no error, and nothing else maintains it.
ValidateTableRelation = false;
}
// Composite value: no TableRelation is possible, and no OnRename on the
// owning table maintains it either. Rots the same way, for the other reason.
field(3; "Source Key"; Code[50])
{
DataClassification = CustomerContent;
}
}
keys
{
key(PK; "Entry No.")
{
Clustered = true;
}
}
}

View file

@ -1,70 +0,0 @@
table 50120 "Source Document"
{
fields
{
field(1; "No."; Code[20])
{
DataClassification = CustomerContent;
}
}
keys
{
key(PK; "No.")
{
Clustered = true;
}
}
// "Source Key" below cannot declare a TableRelation, so the platform cannot
// repoint it. The owning table carries the relationship by hand.
trigger OnRename()
var
DocumentLink: Record "Document Link";
begin
// In OnRename, xRec holds the PREVIOUS primary key while Rec holds the new
// one — the one trigger where that is true regardless of what drove the rename.
DocumentLink.SetRange("Source Key", MakeSourceKey(xRec."No."));
if DocumentLink.FindSet(true) then
repeat
DocumentLink."Source Key" := MakeSourceKey(Rec."No.");
DocumentLink.Modify(true);
until DocumentLink.Next() = 0;
end;
local procedure MakeSourceKey(DocumentNo: Code[20]): Code[50]
begin
exit(StrSubstNo('DOC|%1', DocumentNo));
end;
}
table 50121 "Document Link"
{
fields
{
field(1; "Entry No."; Integer)
{
DataClassification = CustomerContent;
}
// Default ValidateTableRelation: the platform repoints this on rename.
field(2; "Document No."; Code[20])
{
DataClassification = CustomerContent;
TableRelation = "Source Document"."No.";
}
// Composite value — no TableRelation can express it, so the parent's
// OnRename above maintains it explicitly.
field(3; "Source Key"; Code[50])
{
DataClassification = CustomerContent;
}
}
keys
{
key(PK; "Entry No.")
{
Clustered = true;
}
}
}

View file

@ -1,38 +0,0 @@
---
bc-version: [all]
domain: data-modeling
keywords: [validatetablerelation, table-relation, rename, onrename, propagation, dangling-reference, soft-relation]
technologies: [al]
countries: [w1]
application-area: [all]
---
# `ValidateTableRelation = false` suppresses rename propagation, not just input validation
## Description
Renaming a record updates it in all other locations that reference it through a `TableRelation`, with no code. That guarantee has **two** preconditions, and a field failing either one is silently left holding a key that no longer exists.
First, a `TableRelation` must exist. A field whose value is constructed or computed — a composite key, or a value derived from several fields of the target — cannot declare one, so nothing propagates. The field is still a foreign key in intent, but the platform treats it as an opaque value.
Second, and far less obvious: the relation must not carry `ValidateTableRelation = false`. The property name implies it only governs *input validation*, so it looks safe to disable on a field populated by code that already knows the target is valid. It is not. **Disabling it also switches off rename propagation.** The relation still documents intent and still drives lookups, but it no longer keeps the stored value correct.
Both failures are quiet: no error at rename time, and in the first case no input validation either, so a wrong value is never rejected on write.
This is verified behaviour, not inference. A parent renamed once against a child holding three fields — a normal relation, the same relation with `ValidateTableRelation = false`, and a field with no relation — updates only the first.
See also `owning-table-must-delete-dependents-in-ondelete.md` for the delete half of this asymmetry, and `xrec-is-a-before-image-only-in-some-triggers.md` for why `OnRename` is the one trigger where a hand-written fix-up is reliable.
## Best Practice
Leave `ValidateTableRelation` at its default wherever the stored value must stay correct across a rename. When it must be disabled, or when the relationship cannot be expressed as a `TableRelation` at all, the table owning the referenced key carries an explicit `OnRename` that repoints the dependents itself.
See sample: `validate-table-relation-false-suppresses-rename-propagation.good.al`.
## Anti Pattern
`ValidateTableRelation = false` added to silence a validation error, on a field expected to keep tracking its target. The field stops being maintained on rename, and the defect surfaces much later as a reference to a key that no longer exists.
Detection signal: any `ValidateTableRelation = false` on a field that also declares a `TableRelation`. Ask what repoints the value when the target is renamed; if the answer is "the platform", the finding stands.
See sample: `validate-table-relation-false-suppresses-rename-propagation.bad.al`.

View file

@ -1,36 +0,0 @@
table 50130 "Service Request"
{
fields
{
field(1; "No."; Code[20])
{
DataClassification = CustomerContent;
}
field(2; Status; Enum "Service Request Status")
{
DataClassification = CustomerContent;
}
}
keys
{
key(PK; "No.")
{
Clustered = true;
}
}
trigger OnModify()
begin
// Dead branch under any code-driven Modify: from code xRec mirrors Rec, so
// the two Status values are always equal and LogStatusChange never runs.
// Editing the field on a page DOES populate xRec, so this passes manual
// testing and then silently does nothing in a job queue or API call.
if Status <> xRec.Status then
LogStatusChange(xRec.Status, Status);
end;
local procedure LogStatusChange(FromStatus: Enum "Service Request Status"; ToStatus: Enum "Service Request Status")
begin
end;
}

View file

@ -1,58 +0,0 @@
table 50130 "Service Request"
{
fields
{
field(1; "No."; Code[20])
{
DataClassification = CustomerContent;
}
field(2; Status; Enum "Service Request Status")
{
DataClassification = CustomerContent;
}
}
keys
{
key(PK; "No.")
{
Clustered = true;
}
}
// OnModify: xRec mirrors Rec when the write came from code, so it cannot be
// used as a before-image. Re-read the stored row instead — this behaves the
// same whether a page, a job queue or an API drove the write.
trigger OnModify()
var
Previous: Record "Service Request";
begin
if Previous.Get("No.") and (Previous.Status <> Status) then
LogStatusChange(Previous.Status, Status);
end;
// OnRename: xRec IS the before-image of the primary key here, whatever drove
// the rename. This is the one trigger where the idiom is reliable.
trigger OnRename()
begin
RepointDependents(xRec."No.", "No.");
end;
// OnDelete: xRec reflects the record being removed.
trigger OnDelete()
begin
ArchiveRequest(xRec."No.");
end;
local procedure LogStatusChange(FromStatus: Enum "Service Request Status"; ToStatus: Enum "Service Request Status")
begin
end;
local procedure RepointDependents(OldNo: Code[20]; NewNo: Code[20])
begin
end;
local procedure ArchiveRequest(RequestNo: Code[20])
begin
end;
}

View file

@ -1,36 +0,0 @@
---
bc-version: [all]
domain: data-modeling
keywords: [xrec, before-image, onmodify, onrename, oninsert, ondelete, table-trigger, page-driven]
technologies: [al]
countries: [w1]
application-area: [all]
---
# `xRec` is a before-image in `OnRename` and `OnDelete`, but mirrors `Rec` in `OnInsert` and `OnModify` from code
## Description
`xRec` is widely believed to be "the previous record" inside every table trigger, and — in reaction to that — is often dismissed with the folk rule *"`xRec` only works from a page, never from code"*. Both are wrong, and the second is wrong in the place it matters most.
The behaviour is per-trigger. In `OnRename` and `OnDelete`, `xRec` is a genuine before-image regardless of what drove the write. In `OnInsert` and `OnModify`, a **code-driven** write leaves `xRec` mirroring `Rec` — there is no before-image at all — while a **page-driven** write does supply one.
That combination produces a defect that is unusually hard to catch. A comparison such as `if Rec.Status <> xRec.Status then` inside `OnModify` works when a tester clicks through a page, and silently never fires when the same code path runs from a job queue, a batch routine, or an API call. It fails as a no-op, not as an error.
The `OnRename` case is the useful half: because `xRec` there holds the previous primary key even from code, it is the one place a hand-written key fix-up is reliable. Note that in `OnRename` only the key differs between `Rec` and `xRec` — non-key field values are identical on both sides.
See also `validate-table-relation-false-suppresses-rename-propagation.md`, which describes when such a hand-written `OnRename` fix-up is required.
## Best Practice
Use `xRec` for the previous key in `OnRename`, and for the record being removed in `OnDelete`. In `OnModify`, obtain the before-image by re-reading the stored row rather than trusting `xRec`, so the logic behaves identically whether a page, a job queue or an API drove the write.
See sample: `xrec-is-a-before-image-only-in-some-triggers.good.al`.
## Anti Pattern
Comparing `Rec` against `xRec` inside `OnModify` (or `OnInsert`) to detect a change. From code the two are equal, so the branch is dead and whatever it guards never happens.
Detection signal: any read of `xRec` inside `OnModify` or `OnInsert`. Treat "but it works when I test it on the page" as confirmation of the defect rather than a refutation.
See sample: `xrec-is-a-before-image-only-in-some-triggers.bad.al`.

View file

@ -1,24 +0,0 @@
page 50100 "CurrPage Update OAGR Bad"
{
PageType = List;
SourceTable = Customer;
ApplicationArea = All;
layout
{
area(content)
{
repeater(Rows)
{
field("No."; Rec."No.") { }
field(Name; Rec.Name) { }
}
}
}
trigger OnAfterGetRecord()
begin
// Update from OnAfterGetRecord re-enters the trigger on every row.
CurrPage.Update(false);
end;
}

View file

@ -1,26 +0,0 @@
page 50100 "CurrPage Update OAGR Good"
{
PageType = List;
SourceTable = Customer;
ApplicationArea = All;
layout
{
area(content)
{
repeater(Rows)
{
field("No."; Rec."No.") { }
field(Warning; WarningText) { }
}
}
}
var
WarningText: Text[50];
trigger OnAfterGetRecord()
begin
WarningText := CopyStr(Rec.Name, 1, MaxStrLen(WarningText));
end;
}

View file

@ -1,28 +0,0 @@
---
bc-version: [all]
domain: performance
keywords: [currpage-update, onaftergetrecord, list-page, scroll, refresh]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Do not call CurrPage.Update inside OnAfterGetRecord
> Contributions welcome — open a PR to refine or extend this article.
## Description
`OnAfterGetRecord` on a list already runs once per visible row on scroll and refresh. `CurrPage.Update` asks the page to reload, which fires those triggers again. The result is a refresh loop or a stutter on every row paint. Official developer performance guidance lists `CurrPage.Update()` in `OnAfterGetRecord` next to `Modify` as work that must not live there. Sibling of `do-not-modify-in-onaftergetrecord.md` (writes); this file is the client refresh half.
## Best Practice
Put display-only results in page variables assigned in `OnAfterGetRecord` without calling `Update`. If the page must refresh after an action, call `CurrPage.Update(false)` from `OnAction` once, not per row.
See sample: `avoid-currpage-update-in-onaftergetrecord.good.al`.
## Anti Pattern
`trigger OnAfterGetRecord() begin ... CurrPage.Update(); end;` on a list. The signal is `CurrPage.Update` inside `OnAfterGetRecord` or `OnAfterGetCurrRecord` without an explicit user action.
See sample: `avoid-currpage-update-in-onaftergetrecord.bad.al`.

View file

@ -1,20 +0,0 @@
codeunit 50100 "Batch NoSeries Insert Bad"
{
procedure InsertDraftOrders(var Customer: Record Customer)
var
SalesHeader: Record "Sales Header";
SalesSetup: Record "Sales & Receivables Setup";
NoSeries: Codeunit "No. Series";
begin
SalesSetup.Get();
if Customer.FindSet() then
repeat
SalesHeader.Init();
SalesHeader."Document Type" := SalesHeader."Document Type"::Order;
// Per-row GetNextNo locks the number-series line every insert.
SalesHeader."No." := NoSeries.GetNextNo(SalesSetup."Order Nos.", WorkDate());
SalesHeader."Sell-to Customer No." := Customer."No.";
SalesHeader.Insert(true);
until Customer.Next() = 0;
end;
}

View file

@ -1,20 +0,0 @@
codeunit 50100 "Batch NoSeries Insert Good"
{
procedure InsertDraftOrders(var Customer: Record Customer)
var
SalesHeader: Record "Sales Header";
SalesSetup: Record "Sales & Receivables Setup";
NoSeriesBatch: Codeunit "No. Series - Batch";
begin
SalesSetup.Get();
if Customer.FindSet() then
repeat
SalesHeader.Init();
SalesHeader."Document Type" := SalesHeader."Document Type"::Order;
SalesHeader."No." := NoSeriesBatch.GetNextNo(SalesSetup."Order Nos.", WorkDate());
SalesHeader."Sell-to Customer No." := Customer."No.";
SalesHeader.Insert(true);
until Customer.Next() = 0;
NoSeriesBatch.SaveState();
end;
}

View file

@ -1,28 +0,0 @@
---
bc-version: [22..]
domain: performance
keywords: [no-series, getnextno, no-series-batch, savestate, numbersequence, lock]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Batch number-series calls instead of GetNextNo per insert
> Contributions welcome — open a PR to refine or extend this article.
## Description
`Codeunit "No. Series".GetNextNo` on a **gapless (Normal)** series updates and locks the number-series line on every call. A tight `Insert` loop that asks for a number per row serializes every concurrent writer on that series — the classic SaaS posting bottleneck. Training data still copies the per-row C/AL `NoSeriesManagement` shape. Series configured with **Allow Gaps** instead obtain numbers through `NumberSequence` and do not hold the series-line lock between calls, so they are not affected by this pattern. Codeunit `"No. Series - Batch"` issues gapless numbers in memory and writes the series line once via `SaveState`.
## Best Practice
Inside a multi-row insert, call `"No. Series - Batch".GetNextNo` per row and `SaveState` once after the loop when the series must remain gapless. Use `NumberSequence.Next` when holes are allowed. Do not replace a single `OnInsert` `GetNextNo` for one master record; that path is not the hotspot.
See sample: `batch-number-series-instead-of-getnextno-per-row.good.al`.
## Anti Pattern
`NoSeries.GetNextNo(...)` inside `repeat ... Insert ... until Next() = 0` where the series is **gapless** (Allow Gaps = false). Each iteration takes the series-line lock. The signal is `"No. Series"` (not `"No. Series - Batch"`) in a loop that inserts more than one row; do not flag the same pattern when the series has Allow Gaps enabled, as the `NumberSequence` path already avoids the lock.
See sample: `batch-number-series-instead-of-getnextno-per-row.bad.al`.

View file

@ -1,20 +0,0 @@
codeunit 50100 "ChangeCompany Loop Bad"
{
procedure NamesForCustomers(var Buffer: Record Customer)
var
Customer: Record Customer;
Company: Record Company;
begin
Customer.SetLoadFields(Name);
if Buffer.FindSet() then
repeat
if Company.FindSet() then
repeat
// ChangeCompany per customer per company resets caches every row.
Customer.ChangeCompany(Company.Name);
if Customer.Get(Buffer."No.") then
Message(Customer.Name);
until Company.Next() = 0;
until Buffer.Next() = 0;
end;
}

View file

@ -1,19 +0,0 @@
codeunit 50100 "ChangeCompany Loop Good"
{
procedure NamesForCustomers(var Buffer: Record Customer)
var
Customer: Record Customer;
Company: Record Company;
begin
Customer.SetLoadFields(Name);
if Company.FindSet() then
repeat
Customer.ChangeCompany(Company.Name);
if Buffer.FindSet() then
repeat
if Customer.Get(Buffer."No.") then
Message(Customer.Name);
until Buffer.Next() = 0;
until Company.Next() = 0;
end;
}

View file

@ -1,28 +0,0 @@
---
bc-version: [all]
domain: performance
keywords: [changecompany, loop, cache, multi-company, isolation]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Do not call ChangeCompany inside a per-row loop
> Contributions welcome — open a PR to refine or extend this article.
## Description
`ChangeCompany` retargets a record variable to another company's data and drops the in-memory caches bound to the previous company. Calling it once per row in a multi-company scan therefore pays a cache reset on every iteration, even when consecutive rows share a company. Agents treat `ChangeCompany` like a filter. It is an isolation switch.
## Best Practice
Group work by company. Call `ChangeCompany` once per distinct company, then `FindSet`/`Get` that company's rows. If the record variable is reused afterward, call `ChangeCompany()` without a company name to redirect it back to the current company.
See sample: `changecompany-in-loop-drops-caches.good.al`.
## Anti Pattern
`repeat Rec.ChangeCompany(Buffer.Company); Rec.Get(Buffer."No."); until Buffer.Next() = 0` when `Buffer` is not ordered by company, or even when it is — if `ChangeCompany` still runs every row. The signal is `ChangeCompany` inside `repeat`/`while` keyed by a document line rather than by a company loop.
See sample: `changecompany-in-loop-drops-caches.bad.al`.

View file

@ -1,15 +0,0 @@
report 50100 "Cust List ReadOnly Bad"
{
UsageCategory = ReportsAndAnalysis;
ApplicationArea = All;
// Missing DataAccessIntent = ReadOnly; the scan hits the primary replica.
dataset
{
dataitem(Customer; Customer)
{
column(No; "No.") { }
column(Name; Name) { }
}
}
}

View file

@ -1,15 +0,0 @@
report 50100 "Cust List ReadOnly Good"
{
UsageCategory = ReportsAndAnalysis;
ApplicationArea = All;
DataAccessIntent = ReadOnly;
dataset
{
dataitem(Customer; Customer)
{
column(No; "No.") { }
column(Name; Name) { }
}
}
}

View file

@ -1,28 +0,0 @@
---
bc-version: ["16.."]
domain: performance
keywords: [dataaccessintent, read-only, read-scale-out, report, api-page, query]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Set DataAccessIntent ReadOnly on analytical objects
> Contributions welcome — open a PR to refine or extend this article.
## Description
`DataAccessIntent` was introduced at runtime 5.0 (BC 16) and has no effect in earlier versions. Reports, API pages (`PageType = API` with `Editable = false`), and queries that only read can run against a read replica when `DataAccessIntent = ReadOnly`. For queries, replica routing only applies when the query is exposed via OData/API; running a query in AL code is unaffected. Without the property these objects hit the primary replica and compete with posting. Agents omit it because the default is read-write and the object "only reads" in AL. The replica routing is a metadata switch, not something the compiler infers from the absence of `Modify`.
## Best Practice
On report objects and `PageType = API` pages with `Editable = false` that never write, set `DataAccessIntent = ReadOnly`. For query objects, set it when the query is consumed via OData or an API endpoint. Keep the default on objects that insert, modify, or call a write codeunit from a processing-only report.
See sample: `dataaccessintent-readonly-on-analytical-objects.good.al`.
## Anti Pattern
A listing report or API query with no `DataAccessIntent` that scans G/L or sales lines. The object is read-only in practice and still loads the primary.
See sample: `dataaccessintent-readonly-on-analytical-objects.bad.al`.

View file

@ -1,34 +0,0 @@
page 50100 "GuiAllowed OData Guard Bad"
{
PageType = List;
SourceTable = Customer;
ApplicationArea = All;
layout
{
area(content)
{
repeater(Rows)
{
field("No."; Rec."No.") { }
field(Name; Rec.Name)
{
StyleExpr = NameStyle;
}
}
}
}
var
NameStyle: Text;
trigger OnAfterGetRecord()
begin
// UI-only styling still runs for every OData / Edit-in-Excel row.
Rec.CalcFields("Balance (LCY)");
if Rec."Balance (LCY)" > 0 then
NameStyle := 'Attention'
else
NameStyle := 'Standard';
end;
}

View file

@ -1,35 +0,0 @@
page 50100 "GuiAllowed OData Guard Good"
{
PageType = List;
SourceTable = Customer;
ApplicationArea = All;
layout
{
area(content)
{
repeater(Rows)
{
field("No."; Rec."No.") { }
field(Name; Rec.Name)
{
StyleExpr = NameStyle;
}
}
}
}
var
NameStyle: Text;
trigger OnAfterGetRecord()
begin
if not GuiAllowed then
exit;
Rec.CalcFields("Balance (LCY)");
if Rec."Balance (LCY)" > 0 then
NameStyle := 'Attention'
else
NameStyle := 'Standard';
end;
}

View file

@ -1,28 +0,0 @@
---
bc-version: [all]
domain: performance
keywords: [guiallowed, clienttype, odata, edit-in-excel, page-trigger, factbox]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Guard page trigger work with GuiAllowed for OData and Excel
> Contributions welcome — open a PR to refine or extend this article.
## Description
Pages exposed as OData, including Edit in Excel, still run AL page triggers for every row returned. FactBox updates, defaulting, and extra `CalcFields` in `OnAfterGetRecord` therefore run on the web-service path where no UI exists. `GuiAllowed` is false for those sessions. Agents add page logic as if only the browser client will execute it.
## Best Practice
Wrap UI-only work — FactBox refresh, notifications, defaulting that is not part of the web-service contract — in `if GuiAllowed then`. Keep the OData path to field values the API actually returns.
See sample: `guiallowed-guard-on-pages-used-as-odata.good.al`.
## Anti Pattern
Unconditional FactBox or calculation logic in `OnAfterGetRecord` / `OnAfterGetCurrRecord` on a page that is published as a web service or used with Edit in Excel. The signal is trigger work that calls `CurrPage` parts or extra queries without a `GuiAllowed` guard.
See sample: `guiallowed-guard-on-pages-used-as-odata.bad.al`.

View file

@ -1,13 +0,0 @@
codeunit 50100 "HttpClient Holds Locks Bad"
{
procedure SyncCustomerLastName(var Customer: Record Customer)
var
Client: HttpClient;
Response: HttpResponseMessage;
begin
Customer."Search Name" := Customer.Name;
Customer.Modify(false);
// Locks from Modify are held for the entire HTTP wait.
Client.Get(StrSubstNo('https://example.local/sync/%1', Customer."No."), Response);
end;
}

View file

@ -1,62 +0,0 @@
codeunit 50100 "HttpClient Holds Locks Good"
{
procedure SyncCustomerLastName(var Customer: Record Customer)
var
CustomerSyncOutbox: Record "Customer Sync Outbox";
begin
Customer."Search Name" := Customer.Name;
Customer.Modify(false);
// This work item commits or rolls back with the customer change.
CustomerSyncOutbox."Customer No." := Customer."No.";
CustomerSyncOutbox.Insert();
end;
}
table 50100 "Customer Sync Outbox"
{
DataClassification = CustomerContent;
fields
{
field(1; "Entry No."; Integer)
{
AutoIncrement = true;
}
field(2; "Customer No."; Code[20]) { }
}
keys
{
key(PK; "Entry No.")
{
Clustered = true;
}
}
}
codeunit 50101 "Customer Sync Outbox Worker"
{
// Configure this codeunit as a recurring job queue entry.
TableNo = "Job Queue Entry";
trigger OnRun()
var
Customer: Record Customer;
CustomerSyncOutbox: Record "Customer Sync Outbox";
Client: HttpClient;
Response: HttpResponseMessage;
begin
// Only committed work is visible here; a rolled-back change leaves no outbox row.
if not CustomerSyncOutbox.FindFirst() then
exit;
Customer.Get(CustomerSyncOutbox."Customer No.");
Client.Get(StrSubstNo('https://example.local/sync/%1', Customer."No."), Response);
if not Response.IsSuccessStatusCode() then
Error('Customer sync failed with HTTP status %1.', Response.HttpStatusCode());
// Delete only after HTTP completes, so no write lock is held during the call.
CustomerSyncOutbox.Delete();
end;
}

View file

@ -1,30 +0,0 @@
---
bc-version: [all]
domain: performance
keywords: [httpclient, write-transaction, lock, commit, outbound-http, session-block]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Do not call HttpClient inside an open write transaction
> Contributions welcome — open a PR to refine or extend this article.
## Description
The first database write opens an AL write transaction that the runtime holds until the execution completes or `Commit()` runs — see `understand-implicit-transaction-boundary.md`. `HttpClient` blocks the session until the remote call returns. Any locks taken by earlier `Insert`/`Modify`/`Delete` therefore stay held for the HTTP wall-clock time, and interactive users see a spinner. This is not generic "don't block": it is the AL transaction model plus lock lifetime around outbound I/O.
## Best Practice
Defer the HTTP call to a separate session. When the external operation must correspond to a committed database change, insert an outbox work item in the same transaction as that change and process committed outbox rows with a recurring job queue entry. The change and work item then commit or roll back together, and the worker performs HTTP before deleting the item so it holds no write lock during the call. Make the external operation idempotent because a failure after a successful HTTP response can cause the work item to be retried.
A directly created scheduled task is suitable only when its work is independent of the caller's commit. An immediately ready task can run concurrently with the caller, so it must not assume that the caller's writes are already committed. Do **not** use `Commit()` as a general remedy: it irrevocably commits all prior writes in the current transaction, so any subsequent failure cannot roll them back. `Commit()` is appropriate only at top-level entry points where partial persistence is intentional and understood.
See sample: `httpclient-inside-write-transaction-holds-locks.good.al`.
## Anti Pattern
`Modify`/`Insert` followed by `HttpClient` in the same procedure with no `Commit` between them. Detection signal: any `HttpClient` use after a write on the same execution path, especially in posting, page actions, or subscribers.
See sample: `httpclient-inside-write-transaction-holds-locks.bad.al`.

View file

@ -1,16 +0,0 @@
codeunit 50100 "IsEmpty Before FindSet Bad"
{
procedure ListUsCustomerNames()
var
Customer: Record Customer;
begin
Customer.SetLoadFields(Name);
Customer.SetRange("Country/Region Code", 'US');
// IsEmpty does not replace FindSet; it adds a second round-trip.
if not Customer.IsEmpty() then
if Customer.FindSet() then
repeat
Message(Customer.Name);
until Customer.Next() = 0;
end;
}

View file

@ -1,14 +0,0 @@
codeunit 50100 "IsEmpty Before FindSet Good"
{
procedure ListUsCustomerNames()
var
Customer: Record Customer;
begin
Customer.SetLoadFields(Name);
Customer.SetRange("Country/Region Code", 'US');
if Customer.FindSet() then
repeat
Message(Customer.Name);
until Customer.Next() = 0;
end;
}

View file

@ -1,28 +0,0 @@
---
bc-version: [all]
domain: performance
keywords: [isempty, findset, extra-round-trip, existence-check, false-positive]
technologies: [al]
countries: [w1]
application-area: [all]
---
# IsEmpty immediately before FindSet is an extra round-trip
> Contributions welcome — open a PR to refine or extend this article.
## Description
`IsEmpty` is the right API when the caller only needs existence — see `microsoft/knowledge/performance/use-isempty-for-existence-check.md`. It is not a cheap guard in front of a loop that will `FindSet` anyway. Both calls hit the database; `FindSet` already returns false when the filter matches nothing. Agents and reviewers often insert `if not Rec.IsEmpty() then` "for performance" and pay a second query for a result the iterator already provides.
## Best Practice
When the body iterates, open with `if Rec.FindSet() then repeat ... until Next() = 0`. Do not flag a bare `FindSet` loop as missing an `IsEmpty` precondition. Reserve `IsEmpty` for branches that never materialize the row set.
See sample: `isempty-before-findset-is-extra-round-trip.good.al`.
## Anti Pattern
`if not Rec.IsEmpty() then if Rec.FindSet() then repeat`. Also a false-positive review comment that asks to add that guard. The second read does not avoid the first; it duplicates it.
See sample: `isempty-before-findset-is-extra-round-trip.bad.al`.

View file

@ -1,15 +0,0 @@
codeunit 50100 "Login Subscriber IO Bad"
{
[EventSubscriber(ObjectType::Codeunit, Codeunit::"System Initialization", OnAfterLogin, '', false, false)]
local procedure OnAfterLogin()
var
Client: HttpClient;
Response: HttpResponseMessage;
GLEntry: Record "G/L Entry";
begin
// Blocks UI, API, and job-queue session creation until HTTP and SQL finish.
Client.Get('https://example.local/warmup', Response);
GLEntry.SetLoadFields("Entry No.");
if GLEntry.FindLast() then;
end;
}

View file

@ -1,30 +0,0 @@
codeunit 50100 "Login Subscriber IO Good"
{
[EventSubscriber(ObjectType::Codeunit, Codeunit::"System Initialization", OnAfterLogin, '', false, false)]
local procedure OnAfterLogin()
var
TaskId: Guid;
StoredId: Text;
begin
// Guard to interactive sessions only; background task sessions also raise OnAfterLogin.
if not (Session.CurrentClientType() in [ClientType::Web, ClientType::Windows, ClientType::Desktop, ClientType::Tablet, ClientType::Phone]) then
exit;
// Idempotent: TaskExists requires the GUID returned by CreateTask, stored across logins.
if IsolatedStorage.Get('LoginSyncTaskId', DataScope::Company, StoredId) then
if Evaluate(TaskId, StoredId) then
if TaskScheduler.TaskExists(TaskId) then
exit;
TaskId := TaskScheduler.CreateTask(Codeunit::"Login Subscriber IO Work", 0, true, CompanyName(), CurrentDateTime() + 60000);
IsolatedStorage.Set('LoginSyncTaskId', Format(TaskId), DataScope::Company);
end;
}
codeunit 50101 "Login Subscriber IO Work"
{
trigger OnRun()
begin
// Isolated from session creation: outbound I/O is safe here.
end;
}

View file

@ -1,28 +0,0 @@
---
bc-version: [all]
domain: performance
keywords: [oncompanyopen, onafterlogin, session-start, httpclient, subscriber, login]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Session-open subscribers must not do I/O
> Contributions welcome — open a PR to refine or extend this article.
## Description
`OnCompanyOpen`, `OnCompanyOpenCompleted`, and `System Initialization`.OnAfterLogin run while the session is being created. The platform waits until every subscriber returns before the UI, an API call, or a background session can proceed. `HttpClient` or a heavy `FindSet` here delays **every** session type, not just the user who "opened the company". Agents still put warmup sync, license checks, and HTTP probes on these events because they look like an application startup hook.
## Best Practice
Keep company-open subscribers to cheap in-memory work: set a flag, enqueue a job-queue entry, or `TaskScheduler.CreateTask`. Perform HTTP and large SQL after the session is running, in that background work.
See sample: `oncompanyopen-subscribers-must-not-do-io.good.al`.
## Anti Pattern
An `OnAfterLogin` / `OnCompanyOpenCompleted` subscriber that calls `HttpClient` or scans a ledger. Detection signal: `HttpClient`, `FindSet`, or `CalcFields` inside a subscriber bound to those events.
See sample: `oncompanyopen-subscribers-must-not-do-io.bad.al`.

View file

@ -1,31 +0,0 @@
page 50100 "Cue Background Task Bad"
{
PageType = CardPart;
ApplicationArea = All;
layout
{
area(content)
{
cuegroup(Group)
{
field(OpenOrders; OpenOrderCount)
{
Caption = 'Open Sales Orders';
}
}
}
}
var
OpenOrderCount: Integer;
trigger OnOpenPage()
var
SalesHeader: Record "Sales Header";
begin
// Blocks Role Center render on an exact count of sales headers.
SalesHeader.SetRange("Document Type", SalesHeader."Document Type"::Order);
OpenOrderCount := SalesHeader.Count();
end;
}

View file

@ -1,49 +0,0 @@
page 50100 "Cue Background Task Good"
{
PageType = CardPart;
ApplicationArea = All;
layout
{
area(content)
{
cuegroup(Group)
{
field(OpenOrders; OpenOrderCount)
{
Caption = 'Open Sales Orders';
}
}
}
}
var
OpenOrderCount: Integer;
TaskId: Integer;
trigger OnAfterGetCurrRecord()
var
Args: Dictionary of [Text, Text];
begin
CurrPage.EnqueueBackgroundTask(TaskId, Codeunit::"Cue Open Order Count", Args);
end;
trigger OnPageBackgroundTaskCompleted(CompletedTaskId: Integer; Results: Dictionary of [Text, Text])
begin
if Results.ContainsKey('Count') then
Evaluate(OpenOrderCount, Results.Get('Count'));
end;
}
codeunit 50100 "Cue Open Order Count"
{
trigger OnRun()
var
SalesHeader: Record "Sales Header";
Results: Dictionary of [Text, Text];
begin
SalesHeader.SetRange("Document Type", SalesHeader."Document Type"::Order);
Results.Add('Count', Format(SalesHeader.CountApprox()));
Page.SetBackgroundTaskResult(Results);
end;
}

View file

@ -1,28 +0,0 @@
---
bc-version: [15..]
domain: performance
keywords: [page-background-task, cue, rolecenter, enqueuebackgroundtask, ui-thread]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Calculate expensive cues on a page background task
> Contributions welcome — open a PR to refine or extend this article.
## Description
Role-center cues and CardPart totals that run `CalcFields`, scans, or HTTP on the UI thread freeze the shell until they finish. Page background tasks exist to return the page immediately and fill the number later. Enqueue mechanics, cancellation, and the read-only child session are covered in `microsoft/knowledge/ui/page-background-tasks.md`. This file is the performance trigger: a cue whose value is not needed to *open* the page must not run on the render path.
## Best Practice
Bind the cue to a page variable, enqueue a read-only calculation from `OnAfterGetCurrRecord` (not `OnAfterGetRecord` on a list), and apply the result in `OnPageBackgroundTaskCompleted`. Show a placeholder until then.
See sample: `page-background-tasks-for-expensive-cues.good.al`.
## Anti Pattern
`CalcFields` or a ledger `Count` in `OnOpenPage` / `OnAfterGetCurrRecord` of a CueGroup CardPart with no background task. The Role Center waits on SQL the user may never look at.
See sample: `page-background-tasks-for-expensive-cues.bad.al`.

View file

@ -1,20 +0,0 @@
codeunit 50100 "Pass Var Enumerator Bad"
{
procedure ListUsCustomerCities()
var
Customer: Record Customer;
begin
Customer.SetLoadFields(Name);
Customer.SetRange("Country/Region Code", 'US');
if Customer.FindSet() then
repeat
// By-value copy: JIT on City does not update the enumerator.
Message(Customer.Name + ' ' + CityOf(Customer));
until Customer.Next() = 0;
end;
local procedure CityOf(Customer: Record Customer): Text
begin
exit(Customer.City);
end;
}

Some files were not shown because too many files have changed in this diff Show more