Merge main into Finance knowledge domain

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Jesper Schulz-Wedde 2026-09-17 15:04:04 +02:00
commit 639b6d1870
745 changed files with 18699 additions and 2548 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`](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`](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`](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`](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`](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`](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`](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`](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`](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`](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`](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`](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`](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`](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`](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`](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`](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`](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`](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`](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`](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`](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`](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`](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`](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`](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`](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`](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`](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`](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`](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`](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`](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`](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`](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`](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`](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`](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`](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`](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

@ -0,0 +1,12 @@
codeunit 50100 "Rental Profile Install"
{
Subtype = Install;
trigger OnInstallAppPerDatabase()
var
RentalProfile: Record Profile;
begin
RentalProfile.Init();
RentalProfile.Insert(true);
end;
}

View file

@ -0,0 +1,6 @@
profile "RENTAL MANAGER"
{
Caption = 'Rental Manager';
Description = 'Manages rental agreements and equipment availability.';
RoleCenter = "Business Manager Role Center";
}

View file

@ -0,0 +1,26 @@
---
bc-version: [all]
domain: appsource
keywords: [profile-object, profile-table, install-codeunit, role-center, page-customization]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Define profiles as AL objects
## Description
Profiles delivered by a Marketplace extension must be declared as AL `profile` objects. A profile object is validated with its Role Center and page customizations when the extension is compiled and is registered through extension synchronization. Inserting profile-table records from install or setup code bypasses that object lifecycle.
## Best Practice
Declare each app-owned profile with the `profile` object and set its `RoleCenter`, user-facing caption, and optional customizations in AL. Let installation and synchronization register the object.
See sample: [`define-profiles-as-al-objects.good.al`](define-profiles-as-al-objects.good.al).
## Anti Pattern
Install, upgrade, or setup code that creates an app-owned profile by inserting a `Profile` table record. Detection signal: a `Record Profile` variable followed by `Insert` in profile provisioning code.
See sample: [`define-profiles-as-al-objects.bad.al`](define-profiles-as-al-objects.bad.al).

View file

@ -0,0 +1,7 @@
codeunit 50100 "Rental Audit"
{
procedure SetCreatedAt(var RentalAgreement: Record "Rental Agreement")
begin
RentalAgreement."Created At" := CurrentDateTime() + 7200000;
end;
}

View file

@ -0,0 +1,7 @@
codeunit 50100 "Rental Audit"
{
procedure SetCreatedAt(var RentalAgreement: Record "Rental Agreement")
begin
RentalAgreement."Created At" := CurrentDateTime();
end;
}

View file

@ -0,0 +1,26 @@
---
bc-version: [all]
domain: appsource
keywords: [datetime, time-zone, utc, currentdatetime, locale, regional-settings]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Do not hard-code time-zone offsets
## Description
Marketplace extensions run for users and services in many time zones. Adding a fixed offset to a `DateTime` assumes one locale, ignores daylight-saving transitions, and changes an absolute timestamp into an incorrect value for other regions.
## Best Practice
Store and compare `DateTime` values without a manually applied regional offset. Business Central stores `DateTime` values in UTC and presents them according to the client time zone. Keep service contracts time-zone explicit and perform a conversion only when the business requirement identifies a particular zone.
See sample: [`do-not-hard-code-time-zone-offsets.good.al`](do-not-hard-code-time-zone-offsets.good.al).
## Anti Pattern
Adding or subtracting a fixed duration solely to convert `CurrentDateTime` or another timestamp to an assumed local time. Detection signals include fixed hour-sized millisecond values near `DateTime` assignments and comments naming a specific time zone; confirm the duration is an offset rather than a legitimate deadline or schedule interval.
See sample: [`do-not-hard-code-time-zone-offsets.bad.al`](do-not-hard-code-time-zone-offsets.bad.al).

View file

@ -0,0 +1,21 @@
codeunit 50100 "Rental Service"
{
[ServiceEnabled]
procedure CloseAgreement(AgreementNo: Code[20]): Boolean
var
RentalAgreement: Record "Rental Agreement";
begin
if not Confirm(CloseAgreementQst, false, AgreementNo) then
exit(false);
RentalAgreement.Get(AgreementNo);
RentalAgreement.Closed := true;
RentalAgreement.Modify(true);
Message(AgreementClosedMsg, AgreementNo);
exit(true);
end;
var
CloseAgreementQst: Label 'Close rental agreement %1?';
AgreementClosedMsg: Label 'Rental agreement %1 was closed.';
}

View file

@ -0,0 +1,15 @@
codeunit 50100 "Rental Service"
{
[ServiceEnabled]
procedure CloseAgreement(AgreementNo: Code[20]): Boolean
var
RentalAgreement: Record "Rental Agreement";
begin
if not RentalAgreement.Get(AgreementNo) then
exit(false);
RentalAgreement.Closed := true;
RentalAgreement.Modify(true);
exit(true);
end;
}

View file

@ -0,0 +1,26 @@
---
bc-version: [all]
domain: appsource
keywords: [web-service, serviceenabled, guiallowed, message, confirm, strmenu]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Keep web-service paths free of UI calls
## Description
Pages and codeunits exposed as web services run without an interactive client. Calls that require a UI callback, including `Confirm`, `StrMenu`, and modal pages, can terminate the service request instead of completing the operation. `Message` does not raise the callback error: the message is suppressed and logged, making it ineffective for communicating a service result.
## Best Practice
Keep service entry points and every procedure they call free of interactive UI. Return data through the service contract and report validation failures with service-safe error handling. When a procedure is shared with an interactive client, guard UI-only behavior with `GuiAllowed` while preserving the underlying operation.
See sample: [`keep-web-service-paths-free-of-ui-calls.good.al`](keep-web-service-paths-free-of-ui-calls.good.al).
## Anti Pattern
A web-service-exposed page or codeunit calls an interactive UI method directly or indirectly. Detection signals include `Message`, `Confirm`, `StrMenu`, `Page.RunModal`, and confirmation-dialog pages on a service call path. Treat `Message` as suppressed and ineffective, not as a callback failure. Do not flag a controlled `Error` solely because it returns a service fault.
See sample: [`keep-web-service-paths-free-of-ui-calls.bad.al`](keep-web-service-paths-free-of-ui-calls.bad.al).

View file

@ -0,0 +1,15 @@
pageextension 50100 "Rental Customer List" extends "Customer List"
{
actions
{
addafter("Customer Ledger Entries")
{
action(OpenRentalAgreements)
{
ApplicationArea = All;
Caption = 'Rental Agreements';
RunObject = page "Rental Agreement List";
}
}
}
}

View file

@ -0,0 +1,15 @@
pageextension 50100 "Rental Customer List" extends "Customer List"
{
actions
{
addlast(Processing)
{
action(OpenRentalAgreements)
{
ApplicationArea = All;
Caption = 'Rental Agreements';
RunObject = page "Rental Agreement List";
}
}
}
}

View file

@ -0,0 +1,26 @@
---
bc-version: [all]
domain: appsource
keywords: [pageextension, actions, addfirst, addlast, addbefore, addafter]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Place page extension actions with addfirst or addlast
## Description
Place new page-extension actions at the beginning or end of an existing action group with `addfirst` or `addlast`. Anchoring a new action relative to a specific base-app action with `addbefore` or `addafter` couples the extension to an implementation detail that can move or disappear between Business Central releases.
## Best Practice
Choose the semantic action area or group and append or prepend the extension's actions. This keeps placement deterministic without depending on the continued existence of one neighboring action.
See sample: [`place-page-extension-actions-with-addfirst-or-addlast.good.al`](place-page-extension-actions-with-addfirst-or-addlast.good.al).
## Anti Pattern
Using `addbefore` or `addafter` to place newly added actions next to a specific action from another app. The syntax is valid AL, but the placement anchor is brittle for a Marketplace extension.
See sample: [`place-page-extension-actions-with-addfirst-or-addlast.bad.al`](place-page-extension-actions-with-addfirst-or-addlast.bad.al).

View file

@ -0,0 +1,20 @@
page 50100 "Rental Agreement List"
{
PageType = List;
SourceTable = "Rental Agreement";
ApplicationArea = All;
layout
{
area(Content)
{
repeater(Agreements)
{
field("No."; Rec."No.")
{
ApplicationArea = All;
}
}
}
}
}

View file

@ -0,0 +1,21 @@
page 50100 "Rental Agreement List"
{
PageType = List;
SourceTable = "Rental Agreement";
ApplicationArea = All;
UsageCategory = Lists;
layout
{
area(Content)
{
repeater(Agreements)
{
field("No."; Rec."No.")
{
ApplicationArea = All;
}
}
}
}
}

View file

@ -0,0 +1,26 @@
---
bc-version: [all]
domain: appsource
keywords: [usagecategory, tell-me, search, page, report, discoverability]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Set UsageCategory on searchable entry points
## Description
Pages and reports that users are expected to open directly must set `UsageCategory`. Without it, the object is absent from Tell Me and users cannot bookmark it from the web client. Supporting objects such as list parts, dialogs, API pages, and objects reached only through another page do not need to be searchable entry points.
## Best Practice
Set `UsageCategory` to the category that matches the entry point, such as `Lists`, `Tasks`, `ReportsAndAnalysis`, or `Documents`. Also set the appropriate object-level `ApplicationArea` so search results respect feature visibility.
See sample: [`set-usagecategory-on-searchable-entry-points.good.al`](set-usagecategory-on-searchable-entry-points.good.al).
## Anti Pattern
A user-facing page or report intended for direct discovery omits `UsageCategory` or sets it to `None`. Do not infer intent from the object type alone; require evidence that the object is a direct user entry point.
See sample: [`set-usagecategory-on-searchable-entry-points.bad.al`](set-usagecategory-on-searchable-entry-points.bad.al).

View file

@ -0,0 +1,10 @@
codeunit 50100 "Rental Period Defaults"
{
procedure GetPolicyStartDate(): Date
var
PolicyStartDate: Date;
begin
Evaluate(PolicyStartDate, '01/31/2025');
exit(PolicyStartDate);
end;
}

View file

@ -0,0 +1,7 @@
codeunit 50100 "Rental Period Defaults"
{
procedure GetPolicyStartDate(): Date
begin
exit(20250131D);
end;
}

View file

@ -0,0 +1,26 @@
---
bc-version: [all]
domain: appsource
keywords: [date-literal, invariant-date, dateformula, localization, appsourcecop]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Use invariant date literals
## Description
Write fixed dates in AL with the invariant `yyyymmddD` syntax. A locale-dependent text value parsed with `Evaluate` can change meaning or fail under another user's regional settings, which makes the Marketplace extension unreliable across markets.
## Best Practice
Represent a fixed date directly as an AL date literal, such as `20250131D`. Use `CalcDate` with a date formula when the value is relative rather than fixed.
See sample: [`use-invariant-date-literals.good.al`](use-invariant-date-literals.good.al).
## Anti Pattern
Building a fixed date by passing localized text such as `01/02/2025` to `Evaluate`. Detection signal: `Evaluate` converting a hard-coded or label-backed formatted string into a `Date`.
See sample: [`use-invariant-date-literals.bad.al`](use-invariant-date-literals.bad.al).

View file

@ -1,38 +0,0 @@
// Demonstration only. Shows the wrong pattern: raising the integration event inside a TryFunction body.
codeunit 50116 "Payment Processor Bad"
{
[IntegrationEvent(false, false)]
procedure OnBeforeSubmitPayment(var PaymentAmount: Decimal; var Cancel: Boolean)
begin
end;
procedure SubmitPayment(PaymentAmount: Decimal)
var
Success: Boolean;
begin
// TryFunction wraps both the event raise and the gateway call.
Success := TrySubmitPaymentInternal(PaymentAmount);
if not Success then
Error('Payment gateway call failed. Check connectivity and retry.');
end;
[TryFunction]
local procedure TrySubmitPaymentInternal(PaymentAmount: Decimal)
var
Cancel: Boolean;
Client: HttpClient;
Response: HttpResponseMessage;
begin
Cancel := false;
// BAD: event raised inside TryFunction. Any Error() thrown by a subscriber is caught here
// and silently swallowed - the subscriber's error never reaches the caller.
// A subscriber setting Cancel := true is also lost when TryFunction returns false.
OnBeforeSubmitPayment(PaymentAmount, Cancel);
if Cancel then
exit;
Client.Get('https://payments.example.com/submit?amount=' + Format(PaymentAmount), Response);
if not Response.IsSuccessStatusCode() then
Error('HTTP %1', Response.HttpStatusCode());
end;
}

View file

@ -1,38 +0,0 @@
// Demonstration only. Shows the correct pattern: raise the integration event before entering TryFunction.
codeunit 50114 "Payment Processor"
{
[IntegrationEvent(false, false)]
procedure OnBeforeSubmitPayment(var PaymentAmount: Decimal; var Cancel: Boolean)
begin
end;
procedure SubmitPayment(PaymentAmount: Decimal)
var
Cancel: Boolean;
Success: Boolean;
begin
Cancel := false;
// Event raised outside the try scope - subscriber errors propagate normally to the caller.
OnBeforeSubmitPayment(PaymentAmount, Cancel);
if Cancel then
exit;
// Only the operation that can fail transiently lives inside TryFunction.
Success := TryCallPaymentGateway(PaymentAmount);
if not Success then
Error('Payment gateway call failed. Check connectivity and retry.');
end;
[TryFunction]
local procedure TryCallPaymentGateway(PaymentAmount: Decimal)
var
Client: HttpClient;
Response: HttpResponseMessage;
begin
// ... build request, set headers ...
Client.Get('https://payments.example.com/submit?amount=' + Format(PaymentAmount), Response);
if not Response.IsSuccessStatusCode() then
Error('HTTP %1', Response.HttpStatusCode());
end;
}

View file

@ -1,26 +0,0 @@
---
bc-version: [all]
domain: events
keywords: [tryfunction, integration-event, subscriber, error-handling, silent-failure, event-publisher]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Do not raise integration events inside a TryFunction
## Description
A `TryFunction` catches all errors — including errors thrown by event subscribers. When an `[IntegrationEvent]` is raised inside a `TryFunction` body, any error a subscriber raises is silently swallowed by the TryFunction's error boundary. The subscriber's logic fails, the caller sees no error, and the calling code continues as if nothing happened. Subscribers have no way to signal failure to the caller.
## Best Practice
Raise the integration event before entering the TryFunction scope. The event and its subscribers execute outside the error boundary, so subscriber errors propagate normally to the caller. Move only the operation that genuinely needs error isolation (such as an HTTP call or a posting step) inside the TryFunction.
See sample: `avoid-raising-events-inside-try-functions.good.al`.
## Anti Pattern
Raising an integration event inside a TryFunction body. Subscriber failures are caught and discarded by the TryFunction. The subscriber contract — that a subscriber can signal failure to the caller — is silently broken.
See sample: `avoid-raising-events-inside-try-functions.bad.al`.

View file

@ -1,16 +0,0 @@
codeunit 50100 "Event Audit Buffer"
{
SingleInstance = true;
// Unbounded global: every event fires adds an entry for the lifetime of the session.
var
AllEventIds: List of [Guid];
[EventSubscriber(ObjectType::Table, Database::"Sales Header", OnAfterInsertEvent, '', false, false)]
local procedure OnAfterInsertSalesHeader(var Rec: Record "Sales Header")
begin
// No cap. No eviction. No reset. A session that sees ten thousand inserts
// keeps ten thousand GUIDs in memory until the user signs out.
AllEventIds.Add(Rec.SystemId);
end;
}

View file

@ -1,28 +0,0 @@
codeunit 50100 "Event Audit Buffer"
{
SingleInstance = true;
var
RecentEventIds: List of [Guid];
MaxBuffered: Integer;
trigger OnRun()
begin
MaxBuffered := 50;
end;
[EventSubscriber(ObjectType::Table, Database::"Sales Header", OnAfterInsertEvent, '', false, false)]
local procedure OnAfterInsertSalesHeader(var Rec: Record "Sales Header")
begin
// Bounded cache: drop the oldest entry when the cap is reached.
RecentEventIds.Add(Rec.SystemId);
if RecentEventIds.Count() > MaxBuffered then
RecentEventIds.RemoveAt(1);
end;
procedure ResetAtBusinessProcessBoundary()
begin
// Explicit reset point at a natural boundary in the workflow.
Clear(RecentEventIds);
end;
}

View file

@ -1,28 +0,0 @@
---
bc-version: [all]
domain: performance
keywords: [singleinstance, subscriber, event, memory, session]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Avoid growing globals in SingleInstance subscribers
> Contributions welcome — open a PR to refine or extend this article.
## Description
A codeunit with `SingleInstance = true` is allocated once per session and lives until the session ends. Global variables on it are never collected between event fires. A subscriber that accumulates data into a global — buffering payloads, appending to a list, caching without a cap — steadily grows its session footprint for the entire user session. The symptom is memory that only recovers on sign-out, and it surfaces only on long-running sessions.
## Best Practice
Keep the global footprint on a SingleInstance subscriber bounded and intentional: a handful of flags, a setup record, a bounded cache with a maximum size. When cross-event state is genuinely needed, define an explicit reset point — end of a business process, arrival of a specific terminal event — that clears the growing collection.
See sample: `avoid-growing-globals-in-singleinstance-subscribers.good.al`.
## Anti Pattern
A SingleInstance subscriber that appends each event's payload to a global list, dictionary, or temporary record without a cap or cleanup trigger. The list grows for hours, memory pressure builds quietly, and debugging the root cause on a live environment is substantially harder than noticing the unbounded append in code review.
See sample: `avoid-growing-globals-in-singleinstance-subscribers.bad.al`.

View file

@ -1,32 +0,0 @@
table 50100 "Item Ledger Entry (Demo)"
{
fields
{
field(1; "Entry No."; Integer) { DataClassification = SystemMetadata; }
field(2; "Item No."; Code[20]) { DataClassification = CustomerContent; }
field(3; "Posting Date"; Date) { DataClassification = CustomerContent; }
field(4; Quantity; Decimal) { DataClassification = CustomerContent; }
field(5; "Cost Amount"; Decimal) { DataClassification = CustomerContent; }
}
keys
{
key(PK; "Entry No.") { Clustered = true; }
// Write-heavy ledger key: aggregates on this key are read rarely relative
// to INSERT frequency. Keeping SIFT live on every write is net-negative.
key(ByItemAndDate; "Item No.", "Posting Date")
{
SumIndexFields = Quantity, "Cost Amount";
MaintainSIFTIndex = false;
}
// Dashboard-facing key: aggregates read on every session load, underlying
// rows updated infrequently. Keeping SIFT live pays for itself.
key(ByItem; "Item No.")
{
SumIndexFields = Quantity;
MaintainSIFTIndex = true;
}
}
}

View file

@ -1,26 +0,0 @@
---
bc-version: [all]
domain: performance
keywords: [maintainsiftindex, sift, calcsums, flowfield, write-cost]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Choose MaintainSIFTIndex by read-write ratio
> Contributions welcome — open a PR to refine or extend this article.
## Description
`MaintainSIFTIndex` on a key decides whether the SIFT aggregate structure is updated on every `INSERT`, `MODIFY`, and `DELETE` that touches the key's fields. With `Yes`, `CalcSums` and FlowField reads are immediate — but every write pays the cost of updating the aggregate. With `No`, writes are cheaper but the first aggregate read after a change has to rebuild. Neither value is universally correct; the right choice depends on how often the aggregate is read versus how often the underlying rows are written.
## Best Practice
Measure read-to-write ratios for the key's SIFT fields under realistic workloads. Set `MaintainSIFTIndex = Yes` only on keys whose aggregates are read far more often than the rows are written (reporting keys on reference tables, dashboards). Set `No` on keys whose rows are written heavily and whose aggregates are read rarely (transactional ledger entries, import-staging tables).
See sample: `choose-maintainsiftindex-by-read-write-ratio.good.al`.
## Anti Pattern
Leaving `MaintainSIFTIndex = Yes` on every key by reflex or convenience. On write-heavy tables the cumulative cost turns every INSERT or MODIFY into several additional aggregate updates, and the impact compounds in batch imports and posting routines — often without any code-review signal that the property is the cause.

View file

@ -1,23 +0,0 @@
codeunit 50100 "Sales Document Processor"
{
procedure ProcessDocument(var SalesHeader: Record "Sales Header")
begin
// Single top-level load pulls every field any branch might touch.
// Order records pay for Posting Date and Amount Including VAT that
// only the Invoice branch reads, and vice versa.
SalesHeader.SetLoadFields(
"Document Type", "No.", "Sell-to Customer No.",
"Order Date", "Shipment Date", "Completely Shipped",
"Posting Date", "Amount Including VAT");
case SalesHeader."Document Type" of
SalesHeader."Document Type"::Order:
ProcessOrder(SalesHeader);
SalesHeader."Document Type"::Invoice:
ProcessInvoice(SalesHeader);
end;
end;
local procedure ProcessOrder(var SalesHeader: Record "Sales Header") begin end;
local procedure ProcessInvoice(var SalesHeader: Record "Sales Header") begin end;
}

View file

@ -1,25 +0,0 @@
codeunit 50100 "Sales Document Processor"
{
procedure ProcessDocument(var SalesHeader: Record "Sales Header")
begin
// Tier 1: the discriminator and any fields every branch reads.
SalesHeader.SetLoadFields("Document Type", "No.", "Sell-to Customer No.");
case SalesHeader."Document Type" of
SalesHeader."Document Type"::Order:
begin
// Tier 2: extend the load only on the branch that needs these fields.
SalesHeader.SetLoadFields("Order Date", "Shipment Date", "Completely Shipped");
ProcessOrder(SalesHeader);
end;
SalesHeader."Document Type"::Invoice:
begin
SalesHeader.SetLoadFields("Posting Date", "Amount Including VAT");
ProcessInvoice(SalesHeader);
end;
end;
end;
local procedure ProcessOrder(var SalesHeader: Record "Sales Header") begin end;
local procedure ProcessInvoice(var SalesHeader: Record "Sales Header") begin end;
}

View file

@ -1,28 +0,0 @@
---
bc-version: [all]
domain: performance
keywords: [setloadfields, case, conditional, branch, field-loading]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Load common fields before branching on case
> Contributions welcome — open a PR to refine or extend this article.
## Description
When record processing branches on state, different branches typically read different fields. A single `SetLoadFields` at the top listing every field any branch might touch pulls more data than any individual execution path needs — on the hot path, the rest is loaded for nothing. A two-tier approach matches loading to actual usage: load the fields the `case` expression evaluates plus any fields every branch uses, then add a branch-local `SetLoadFields` inside each branch for that branch's extra fields.
## Best Practice
Before the `case`, call `SetLoadFields` with the minimal set — the discriminator field and fields common to every branch. Inside each branch, before the first access to a branch-specific field, add a second `SetLoadFields` covering those fields. The platform honors the in-branch call for the next record operation, so the extra data is fetched only when the branch runs.
See sample: `load-common-fields-before-branching-on-case.good.al`.
## Anti Pattern
A single top-level `SetLoadFields` enumerating every field any branch might read. On records whose state routes them to the fast common branch, the rarely-needed fields are still loaded — the optimization becomes a net-neutral or net-negative change on the hot path.
See sample: `load-common-fields-before-branching-on-case.bad.al`.

View file

@ -1,18 +0,0 @@
codeunit 50100 "Item Reindex Queue"
{
procedure QueueItemsForReindex(CategoryCode: Code[20])
var
Item: Record Item;
ReindexQueue: Codeunit "Reindex Queue";
begin
// Default full-record load. Description, Unit Price, Inventory, and
// every other column are fetched across the wire and held in memory
// for the whole loop - the body only ever reads "No.".
Item.SetRange("Item Category Code", CategoryCode);
if Item.FindSet() then
repeat
ReindexQueue.Enqueue(Item."No.");
until Item.Next() = 0;
end;
}

View file

@ -1,17 +0,0 @@
codeunit 50100 "Item Reindex Queue"
{
procedure QueueItemsForReindex(CategoryCode: Code[20])
var
Item: Record Item;
ReindexQueue: Codeunit "Reindex Queue";
begin
// Only the primary key is used in the loop body; load nothing else.
Item.SetLoadFields("No.");
Item.SetRange("Item Category Code", CategoryCode);
if Item.FindSet() then
repeat
ReindexQueue.Enqueue(Item."No.");
until Item.Next() = 0;
end;
}

View file

@ -1,28 +0,0 @@
---
bc-version: [all]
domain: performance
keywords: [setloadfields, primary-key, reference, existence-check, memory]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Load only primary key fields for reference work
> Contributions welcome — open a PR to refine or extend this article.
## Description
Work that uses a record only for its identity — passing it to another procedure that will re-fetch what it needs, queueing a key for later processing, running existence checks, or building a reference collection — does not need non-key payload fields. `SetLoadFields` with only the primary key fields loads the minimum that preserves record identity while skipping everything else. On wide tables with large text, BLOB, or media fields the difference in memory and transfer is substantial.
## Best Practice
When the iterating code's body touches only primary key fields (or passes the record to another procedure that will apply its own `SetLoadFields`), declare `SetLoadFields` with just the primary key fields before applying filters and calling `FindSet`. Callers downstream that need more fields issue their own `Get` or extend the load explicitly.
See sample: `load-only-primary-key-fields-for-reference-work.good.al`.
## Anti Pattern
Using the default full-record load in loops whose body only reads the primary key, or forwards the record to another codeunit that immediately re-queries. The non-key payload is fetched across the wire and held in memory for the duration of the loop, then discarded unread.
See sample: `load-only-primary-key-fields-for-reference-work.bad.al`.

View file

@ -1,24 +0,0 @@
codeunit 50100 "Recent Orders Summary"
{
procedure SummarizeRecentOrders(StartDate: Date; EndDate: Date)
var
SalesHeader: Record "Sales Header";
begin
// "Document Type" and "Document Date" are listed in SetLoadFields even
// though they appear only in filters. Per-row values are transferred
// for columns the processing body never reads.
SalesHeader.SetLoadFields(
"Document Type", "Document Date",
"No.", "Sell-to Customer No.", "Amount Including VAT");
SalesHeader.SetRange("Document Type", SalesHeader."Document Type"::Order);
SalesHeader.SetRange("Document Date", StartDate, EndDate);
if SalesHeader.FindSet() then
repeat
Emit(SalesHeader."No.", SalesHeader."Sell-to Customer No.", SalesHeader."Amount Including VAT");
until SalesHeader.Next() = 0;
end;
local procedure Emit(No: Code[20]; CustNo: Code[20]; Amount: Decimal) begin end;
}

View file

@ -1,22 +0,0 @@
codeunit 50100 "Recent Orders Summary"
{
procedure SummarizeRecentOrders(StartDate: Date; EndDate: Date)
var
SalesHeader: Record "Sales Header";
begin
// "Document Type" and "Document Date" are used only in the filters below.
// The database index handles them; there is no need to load their values
// into AL memory for every row.
SalesHeader.SetLoadFields("No.", "Sell-to Customer No.", "Amount Including VAT");
SalesHeader.SetRange("Document Type", SalesHeader."Document Type"::Order);
SalesHeader.SetRange("Document Date", StartDate, EndDate);
if SalesHeader.FindSet() then
repeat
Emit(SalesHeader."No.", SalesHeader."Sell-to Customer No.", SalesHeader."Amount Including VAT");
until SalesHeader.Next() = 0;
end;
local procedure Emit(No: Code[20]; CustNo: Code[20]; Amount: Decimal) begin end;
}

View file

@ -1,28 +0,0 @@
---
bc-version: [all]
domain: performance
keywords: [setloadfields, filter, field-exclusion, index]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Omit filter-only fields from SetLoadFields
> Contributions welcome — open a PR to refine or extend this article.
## Description
Fields used only in `SetRange` and `SetFilter` do their work at the database level using indexes; their values never need to be loaded into AL memory for the filter to apply. Listing such fields in `SetLoadFields` costs the transfer and memory footprint of every row's value for no functional benefit. Distinguishing filter-only fields from processing fields keeps the loaded column set as narrow as the iterating code actually reads.
## Best Practice
Include in `SetLoadFields` exactly the fields the iterating code reads. Fields referenced only in `SetRange`/`SetFilter` stay out of the list — filtering continues to work correctly because the database uses the index. Treat the audit as "what does the `repeat…until` block touch?" rather than "what does this procedure mention?".
See sample: `omit-filter-only-fields-from-setloadfields.good.al`.
## Anti Pattern
Listing every field the procedure mentions in `SetLoadFields`, including date-range or status fields that appear only in filters. The loaded record now carries per-row values for columns the processing body never reads, inflating memory and network cost without changing any behavior.
See sample: `omit-filter-only-fields-from-setloadfields.bad.al`.

View file

@ -1,26 +0,0 @@
codeunit 50100 "Document Router"
{
procedure Route(SalesHeader: Record "Sales Header")
begin
// Alphabetical ordering. Every Order (the ~85% common case) evaluates
// "Credit Memo", "Invoice", and "Quote" before matching.
case SalesHeader."Document Type" of
SalesHeader."Document Type"::"Credit Memo":
RouteCreditMemo(SalesHeader);
SalesHeader."Document Type"::Invoice:
RouteInvoice(SalesHeader);
SalesHeader."Document Type"::Quote:
RouteQuote(SalesHeader);
SalesHeader."Document Type"::Order:
RouteOrder(SalesHeader);
SalesHeader."Document Type"::"Return Order":
RouteReturnOrder(SalesHeader);
end;
end;
local procedure RouteOrder(SalesHeader: Record "Sales Header") begin end;
local procedure RouteInvoice(SalesHeader: Record "Sales Header") begin end;
local procedure RouteQuote(SalesHeader: Record "Sales Header") begin end;
local procedure RouteCreditMemo(SalesHeader: Record "Sales Header") begin end;
local procedure RouteReturnOrder(SalesHeader: Record "Sales Header") begin end;
}

View file

@ -1,25 +0,0 @@
codeunit 50100 "Document Router"
{
procedure Route(SalesHeader: Record "Sales Header")
begin
// In this deployment Orders are ~85% of posting calls, Invoices ~12%,
// and the rest are edge cases. The hot branch goes first.
case SalesHeader."Document Type" of
SalesHeader."Document Type"::Order:
RouteOrder(SalesHeader);
SalesHeader."Document Type"::Invoice:
RouteInvoice(SalesHeader);
SalesHeader."Document Type"::"Credit Memo":
RouteCreditMemo(SalesHeader);
SalesHeader."Document Type"::"Return Order":
RouteReturnOrder(SalesHeader);
else
Error('Unexpected document type %1', SalesHeader."Document Type");
end;
end;
local procedure RouteOrder(SalesHeader: Record "Sales Header") begin end;
local procedure RouteInvoice(SalesHeader: Record "Sales Header") begin end;
local procedure RouteCreditMemo(SalesHeader: Record "Sales Header") begin end;
local procedure RouteReturnOrder(SalesHeader: Record "Sales Header") begin end;
}

View file

@ -1,28 +0,0 @@
---
bc-version: [all]
domain: performance
keywords: [case, branch, frequency, control-flow, hot-path]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Order case branches by frequency
> Contributions welcome — open a PR to refine or extend this article.
## Description
The AL `case` statement evaluates branches in the order they appear. When the distribution of the discriminator is heavily skewed — one or two values handle the vast majority of records, and the rest handle edge cases — the average cost of the statement is dominated by how many branches precede the common one. For evenly distributed discriminators the order does not matter; for skewed distributions it changes the hot-path cost of every call site.
## Best Practice
Where the runtime frequency of values is known or measurable, list the common branches first. An `else` arm that handles unexpected values belongs last. When the common branch is also the simplest to evaluate, the placement compounds: the hot path is both short and cheap, and the uncommon branches are never touched on typical records.
See sample: `order-case-branches-by-frequency.good.al`.
## Anti Pattern
Ordering branches alphabetically, by enum declaration order, or by "logical grouping" when the runtime distribution is heavily skewed. Every common record pays the cost of evaluating every uncommon branch first; on a posting routine processing thousands of rows the overhead is measurable.
See sample: `order-case-branches-by-frequency.bad.al`.

View file

@ -1,19 +0,0 @@
codeunit 50100 "Stale Quote Cleanup"
{
procedure ClearExpiredQuotes(CutoffDate: Date)
var
SalesHeader: Record "Sales Header";
begin
SalesHeader.SetRange("Document Type", SalesHeader."Document Type"::Quote);
SalesHeader.SetFilter("Document Date", '<%1', CutoffDate);
SalesHeader.SetRange(Status, SalesHeader.Status::Open);
// One SQL DELETE per row. On a 10k-row cleanup, minutes instead of
// under a second - and the OnDelete trigger has no logic this call
// needs to run.
if SalesHeader.FindSet() then
repeat
SalesHeader.Delete();
until SalesHeader.Next() = 0;
end;
}

View file

@ -1,17 +0,0 @@
codeunit 50100 "Stale Quote Cleanup"
{
procedure ClearExpiredQuotes(CutoffDate: Date)
var
SalesHeader: Record "Sales Header";
begin
// OnDelete on Sales Header carries no logic this call depends on:
// expired quotes have no ledger entries, shipments, or downstream state.
SalesHeader.SetRange("Document Type", SalesHeader."Document Type"::Quote);
SalesHeader.SetFilter("Document Date", '<%1', CutoffDate);
SalesHeader.SetRange(Status, SalesHeader.Status::Open);
// Single SQL DELETE. Orders of magnitude faster than FindSet + Delete
// once the filtered set exceeds a handful of rows.
SalesHeader.DeleteAll();
end;
}

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