ADR 0003: Sourcing agent writes through proposals¶
- Status: accepted (2026-11-03)
- Scope: the autonomous sourcing agent (Copilot Studio) and every record it creates or causes to be created in ProjectIQ. Design: sourcing agent design; user manual: Sourcing agent
Context¶
ADR 0001 keeps Copilot and Cowork read-only: they cannot call the op365_* Custom APIs, and a custom MCP server is a later decision. The sourcing agent has the opposite job. It must act: route purchase requests, invite suppliers, recommend an award, check invoices, send reminders. But purchasing commits company money and creates obligations to suppliers, and every status change in ProjectIQ is protected by rules that depend on who acts (project manager, buyer, Purchasing Approver, segregation of duties between order and invoice) and is audited under that person.
An autonomous agent that wrote with its own identity would either bypass those rules (because its application user is powerful) or be unable to do anything (because it is not a project manager). It would also make the audit trail say that "the robot" approved an invoice.
flowchart LR
A[Sourcing agent<br/>application user] -->|creates| P[(Agent Proposal<br/>Pending)]
P -->|Teams card or app| H{Person decides}
H -->|Reject + note| R[Rejected]
H -->|Approve| D[op365_DecideAgentProposal]
D -->|impersonating the person| C[Existing Custom APIs<br/>CreateRfq, SendRfq, AwardRfq, ...]
D -.->|action failed: call rolled back| E[Caller records the failure<br/>op365_RecordProposalError]
Decision¶
- The agent writes only proposals. It prepares a decision and writes an Agent Proposal row (Kind Proposal, Insight or Log) with the rationale and the evidence of the analysis. Its role OP365 Sourcing Agent can read the data and create proposals; it cannot write purchasing tables and cannot decide. It creates no drafts: a rejected proposal leaves nothing behind.
- A person decides. A proposal is approved or rejected from a Teams adaptive card or from the app. Rejecting requires a note, which is the feedback to tune the agent. Proposals expire (5 days by default) and become Superseded when their target has moved on.
- The action runs as the person who approved (impersonation).
op365_DecideAgentProposalchecks that the decider may decide that action type and then calls the existing Custom API throughIOrganizationServiceFactory.CreateOrganizationService(deciderId). Status rules,EnsureManager, the second-level approver rule and auditing therefore apply to the person, not to the agent. The role OP365 Buyer is added to those who may do the purchasing actions; invoice proposals stay with the Purchasing Approver or an administrator. - Trust in
DecidedByonly fromop365_SourcingAgentUser. The Teams flow runs with the application user's connection and tells the API who answered the card (DecidedBy). The API accepts that parameter only when the caller is the application user named in the environment variableop365_SourcingAgentUser, and checks the caller before it looks the name up, so nobody can probe user names or decide in somebody else's name. The agent never decides for itself: its calls withoutDecidedByare refused. Unset variable: nobody is trusted. That connection is a dedicated connection reference,op365_dataverse_agent("ProjectIQ Dataverse (sourcing agent)"), used only by the Agent proposal approval flow; the other flows keepop365_dataverse, so they never run as the agent. - The failure is recorded by the caller. When the approved action fails, the whole
op365_DecideAgentProposalcall rolls back (nothing is half done), so the API itself cannot write the failure. The caller, which knows the call failed, invokesop365_RecordProposalError(the card flow, the app command): Pending becomes Failed with the message, and Retry returns it to Pending.op365_RecordProposalErrorresolves the decider likeop365_DecideAgentProposal(the caller, orDecidedBywhen the caller is the agent, which must pass it) and requires that the decider may decide the proposal, so a person who may not decide it cannot turn it into Failed from Teams either. - One open proposal per subject (
DedupKey, enforced by a plugin), proposals are not editable after creation, and the status changes only through the Custom APIs. - Everything ships in the one solution and is off by default: table, Custom APIs, roles, variables and flows;
op365_SourcingAgentEnabledis No.
Consequences¶
- The buyer keeps control: nothing is created, sent or paid without a click of an authorised person, and the audit trail shows that person.
- Every proposal costs a decision. Autonomous execution, thresholds for autonomy and negotiation with suppliers are out of scope and would need a new ADR.
- A new action type needs three things: a choice value in
ProposalLogic.csand the table, its decider rule inProposals.CanDecide, and its branch inDecideAgentProposal. The choice values must not be reordered. - The application user and its two connections (Teams, Dataverse) are manual steps per environment (Administration). The Dataverse connection of
op365_dataverse_agentmust be owned by (sign in as) the application user and must not be shared or reused by other flows, orDecidedByis refused (another identity) or other flows would run as the agent. - Impersonation needs the platform to allow it for plugins; the end-to-end behaviour is checked by
tools/dataverse/sourcing_smoke.pyin Dev. - Proposals are not exposed on the supplier portal. The Evidence is protected by the OP365 Cost Visibility column security profile.
- ADR 0001 stays valid for Copilot and Cowork: they remain read-only; only the sourcing agent writes, and only through proposals.