Skip to content
Mikita Daroshkin
Go back

The permission model was built for people holding jobs

52 min read

A national health plan’s employee-services platform runs on an HCM holding roughly 65,000 workers, and the ask was ordinary enough: put an agent fleet in front of it so people can ask about their pay date, their coverage, their time off, their onboarding tasks, without opening a ticket. The model work is not the hard part. The hard part shows up in week one, in a room with the security administrator, when you try to answer what should be a simple question. What is this thing allowed to see?

The HCM has a good answer for a person. It has a good answer for a nightly integration. But it has no answer for a fleet of agents acting one at a time on behalf of whichever of 65,000 people happens to be asking, because permission in a system of record attaches to a principal that holds a job, and an agent holds no job. Workday’s documentation is unusually direct about the shape of this. In Integration App Security, domain security policies grant access to security groups, and the groups it lists are user-based, role-based, job-based, integration groups, and other context-sensitive groups. Four of those five categories describe humans in positions. The fifth is the one your agent will end up in.

This post is about what happens in that gap on a real deployment: what you actually mint and what it costs, how the eligibility of a business process gets decided before any of it, where a benefits question stops being a support ticket and becomes a disclosure decision, what one of the four permitted writes actually sends over the wire, why the person who owns a report schedule ends up owning your p95, and a metric that scored people giving up as a win. Workday is the worked example because it is the one I built against, and the seam generalizes, so I put the same question to Salesforce, ServiceNow, SAP and Palantir Foundry, whose answers differ more in vocabulary than in shape. Then I close on the primitive that would make most of this unnecessary, which turns out to have shipped already, in a documentation page nobody announced.

What the tenant thinks an agent is

Start with the account. Workday’s integration surface authenticates as an Integration System User, and its own developer documentation says the quiet part out loud on the Extend security page: “The ISU authentication scheme is always a security exception,” and “Because the ISU access doesn’t typically align with the tenant configurations for end users, you must consider additional security checks in your app.” That is the vendor telling you that authorization is now your problem. Everything after this sentence is me taking that instruction seriously.

An ISU is not a worker. It has no supervisory organization, no job profile, no manager, and therefore none of the constrained security groups that scope a real person’s access to their own organization. You grant it membership in an Integration System Security Group, and you grant that group permissions on security domains, with View for reads and Modify for writes, so the unit of the grant is a domain, which is a functional area, and a business process, which is a transaction type. There is no unit that means “this worker’s own record,” and no unit that means “only while acting for the person who asked.”

So the design collapses into an obvious and slightly uncomfortable move. You mint the account, you grant it the union of every domain any tool needs, and then you write the per-request check yourself, in your own code, on the side of the wire Workday cannot see. Three ISUs in the end, split by blast radius rather than by convenience: one read-only account for reporting, one for worker-context reads, one that may initiate exactly four business processes. The write account holds Change Home Contact Information, Request Time Off, Change Benefit Elections, and Request Employment Verification, and nothing else, because every process you add is a step someone has to explain to an auditor. The read account is where the union bites, since it holds Get on nine domains, the tenth being the one that failed access review, and nine is far more than any single question needs, though there is no way to say so in the tenant: a tool answering “who is my manager” and a tool answering “when is my next pay date” read through different domains, and the account authenticates, not the tool.

The default response widens that union again. Pull the Human Resources WSDL and read the annotation on Worker_Response_GroupType: if the request does not set any values for the response group, the response by default returns Reference, Personal Data, Employment Data, Compensation Data, Organization Data, and Role Data. Compensation is in the default. A Get_Workers call written the lazy way, to answer a question about a manager’s name, asks for salary. Whether it gets salary depends entirely on what the ISU’s security groups hold, which means the request shape is not the privilege boundary and never was. Every call is explicit about what it wants anyway, but the narrowness is documentation of intent and not enforcement:

<soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/"
                  xmlns:bsvc="urn:com.workday/bsvc">
  <soapenv:Body>
    <bsvc:Get_Workers_Request bsvc:version="v46.2">
      <bsvc:Request_References>
        <bsvc:Worker_Reference>
          <bsvc:ID bsvc:type="Employee_ID">100241</bsvc:ID>
        </bsvc:Worker_Reference>
      </bsvc:Request_References>
      <bsvc:Response_Filter>
        <!-- the entry moment defaults to now, per request; pin it so a retry
             and a paged read see the same version of the table -->
        <bsvc:As_Of_Entry_DateTime>2026-07-17T09:00:00-07:00</bsvc:As_Of_Entry_DateTime>
        <bsvc:Page>1</bsvc:Page>
        <bsvc:Count>1</bsvc:Count>
      </bsvc:Response_Filter>
      <bsvc:Response_Group>
        <bsvc:Include_Reference>true</bsvc:Include_Reference>
        <bsvc:Include_Personal_Information>false</bsvc:Include_Personal_Information>
        <bsvc:Include_Employment_Information>true</bsvc:Include_Employment_Information>
        <bsvc:Include_Compensation>false</bsvc:Include_Compensation>
        <bsvc:Include_Organizations>true</bsvc:Include_Organizations>
      </bsvc:Response_Group>
    </bsvc:Get_Workers_Request>
  </soapenv:Body>
</soapenv:Envelope>

Count accepts a value between 1 and 999 and defaults to 100, so a batch integrator’s habit of leaving it alone means every worker lookup pulls a hundred records. The WSDL’s own documentation on Page tells you that if you set the page parameter you must also specify As_Of_Entry_Date, which is not the name of any element in Response_FilterType; the element is As_Of_Entry_DateTime, and if you omit it the entry moment defaults to the current time, independently, on every request, so page one and page two are two different snapshots of a changing table, and the bug that produces is a worker who appears twice or not at all in a paged read. The client pins the entry moment on every paged call for that reason, and there is a test that pages a table while a writer is running.

Which questions the agent is allowed to have

None of the above tells you which processes an agent should touch. That is decided in a discovery workshop in the first fortnight, and it is the highest leverage two days of the engagement, because it is where the client’s own risk owners decide the scope instead of discovering it later in an incident.

The format that worked: take twelve months of case data out of the service platform, categorise it, and go through the categories in a room holding the service-desk lead, the benefits administrator, the HRIS owner, the security administrator, and someone from privacy. For each category, one question. If this answer is wrong, who is harmed and can it be undone? Not “is the model good at this,” which nobody in the room can answer, but which is also the wrong question to be asking in that room. 171,000 cases a year across 65,000 workers, about 2.6 per person, and the room came out with a per-category eligible share, where eligible means the agent may attempt it, not that it will succeed.

Annual employee-services cases by category with the share the discovery workshop cleared the agent to attempt: benefits is the largest queue at 53,010 cases and the second least eligible at 34 percent, leave is 11 percent, payroll and time is 72 percent, policy 81 percent, and the total is 92,100 eligible of 171,000

53.9% of the volume, 92,100 cases a year. The picture carries the shares but not the reason attached to each ceiling, and the reason is the useful artifact, because it is what a new engineer reads two years later when someone asks why the agent will not answer a question it obviously could.

CategoryCases a yearEligibleWhat sets the ceiling
benefits53,01034%only enrollment status leaves the plan-sponsor firewall
payroll and time46,17072%a net pay dispute needs a payroll analyst and a recalculation
job and org data30,78066%anything that moves a supervisory organization is a manager transaction
onboarding23,94058%the I-9 and the background check live in third-party systems
leave10,26011%certifications are medical and the remainder is case-managed
policy and other6,84081%whether the policy is published anywhere the agent can cite

n = 171,000 cases over twelve months, exported from the service platform and categorised on its existing queue taxonomy; the eligible shares are the workshop’s own numbers and have not moved since. Monthly volume runs from 8,500 in December to 26,400 in November, so everything downstream is computed on the annual total, because open enrollment makes any single month a lie. The outcome split under those attempts, over the twelve months after go-live, is 58,944 answered end to end, 26,709 escalated with a prepared case, and 6,447 refused at the gate.

That refusal count is a number I would look at first on any similar system. It is 7% of attempts, and it is the agent declining to answer something inside its nominal scope. Seven percent isn’t a target anyone set, but if it were near zero I would go and check that the gate still ran.

A benefits question is a disclosure decision

Here is the part that reframed the whole build for me, and I got to it later than I should have.

The client is a health plan, and it is also the sponsor of its own employees’ group health plan. So when an employee asks the employee-services assistant whether their MRI was covered, the question is not a support ticket that happens to be about health. It is a request that protected health information flow from the group health plan to the plan sponsor, and HIPAA has a specific and awkward answer for that flow. 45 CFR 164.504(f) requires the plan documents to provide adequate separation between the plan and the sponsor, and the way it requires it is by making the plan documents “describe those employees or classes of employees or other persons under the control of the plan sponsor to be given access to the protected health information to be disclosed.” The compliance artifact is a roster of people.

The minimum necessary standard lands in the same place from the other direction. 45 CFR 164.514(d)(2) asks a covered entity to identify “those persons or classes of persons, as appropriate, in its workforce who need access to protected health information to carry out their duties,” and then, for each person or class, the categories of information they need. And workforce is defined as employees, volunteers, trainees, and other persons whose conduct is under the direct control of the entity. Every unit of account in that machinery is a person with duties. This is the same shape as the security group taxonomy from the top of the post, arrived at independently by a rule writer in 2000, and noticing that the two agree was the moment the design stopped being a debate.

There is a carve-out that turned out to be the whole answer. Paragraph (f)(1)(iii) lets the plan disclose to the sponsor whether an individual is participating in the plan, or is enrolled in or has disenrolled from an issuer offered by it, so enrollment status is not inside the firewall, and the agent’s benefits scope is exactly that: am I covered, when does coverage start, did my election save. Everything past it goes to a person who is named in the plan documents, which is why benefits eligibility came out at 34% rather than at zero or at one, since the 34% isn’t a confidence estimate but the share of the benefits queue that fits through a specific paragraph.

The tool surface encodes it. Four tools cover benefits, one per business question rather than one per API call, because a tool per call puts the model in charge of composing a read out of parts and leaves the gate with nothing to reason about. The whole fleet runs nineteen read tools over the nine domains and four write tools over the four processes; several domains carry three or four tools each, which is how nineteen tools fit into nine grants. Every tool is registered in code against the domain it reads through, so the union from the first section is derivable from the tool list instead of from a spreadsheet somebody maintains. Two of the benefits four:

TOOLS = [
    {
        "name": "benefit_enrollment_status",
        "description": (
            "Return whether the asking worker is currently participating in the group "
            "health plan, and the effective dates of enrollment or disenrollment. Call "
            "this for 'am I covered', 'when does my coverage start', 'did my election "
            "go through'. Returns no claims, no diagnoses, no dependent health "
            "information; those questions must be escalated instead."
        ),
        "strict": True,
        "input_schema": {
            "type": "object",
            "properties": {
                "worker_id": {"type": "string"},
                "plan_year": {"type": "integer", "enum": [2024, 2025, 2026]},
            },
            "required": ["worker_id"],
            "additionalProperties": False,
        },
    },
    {
        "name": "escalate_to_benefits_administration",
        "description": (
            "Hand the case to a named person on the plan-administration roster. Call "
            "this for any question that needs claims, diagnoses, dependent health "
            "information, or a leave certification. Do not attempt to answer those."
        ),
        "strict": True,
        "input_schema": {
            "type": "object",
            "properties": {
                "worker_id": {"type": "string"},
                "reason": {
                    "type": "string",
                    "enum": ["claims", "coverage_dispute", "dependent_eligibility",
                             "leave_certification", "other"],
                },
                "summary": {"type": "string"},
            },
            "required": ["worker_id", "reason", "summary"],
            "additionalProperties": False,
        },
    },
]

Escalation is a tool the model calls, not a fallthrough when nothing matches. A fallthrough is silent when it is wrong, whereas a tool call is a logged decision with a reason code, and the reason code is what a privacy officer asks for.

strict: True is doing real work here, and it does less of it than I assumed on a first read. Strict tool use constrains sampling to a grammar compiled from the schema, so the enum on reason is a guarantee and the model cannot emit a reason code that is not on the list. plan_year started life as an integer with a minimum of 2020 and a maximum of 2100, and I wrote that bound believing it was a guarantee of the same kind, whereas the JSON Schema limitations that strict tool use shares with structured outputs list numerical constraints such as minimum, maximum and multipleOf as unsupported, along with minLength and maxLength, and if you send one anyway “you’ll receive a 400 error with details.” A 400 is the kind way to find that out. The unkind way is a schema derived from a Pydantic model or a Zod object, where the SDK removes the unsupported constraint before the call, restates it in the field description as English along the lines of “Must be at least 100”, and validates the response against your original schema once the model has already produced a value. Nothing in your source file changes, but the bound has stopped being something the sampler cannot violate and become a sentence the model may or may not weigh, plus a check that runs on a value that already exists.

So the schema has two halves and only one of them is a boundary. Type, enum, const, required, additionalProperties set to false, anyOf, allOf, $ref, default, string format from a fixed list, array minItems (0 or 1, and no other value), and pattern over a restricted set of regex features all compile into the grammar and cannot be violated. What does not compile is magnitude, minimum, maximum and multipleOf, and string length, minLength and maxLength. The split is not constraints-on-values against everything else, which is how I first described it to the team and had to walk back: minItems is a length constraint and it survives, because a list’s length is something a grammar can count while a number’s size is not. That is the response group argument again, one layer up, and it is why plan_year is now an enum of the years the plan actually has, which is the only way to express a range in a grammar and is uglier than the bound it replaced.

What a plan_year of 9999 costs is worth being precise about, because I overstated it internally for a while. It is not an unchecked value reaching the system of record. Both paths close: send the raw schema with minimum and you get a 400 before any sampling happens, and let the SDK derive it and the response is validated against your original schema, which is the sentence one paragraph up. What you lose is the guarantee’s location. A grammar constraint cannot be violated, so it costs nothing; a post-hoc validation is a burned turn, an exception on a code path you have to write, and a retry the model may spend on the same wrong value. Cheap in isolation, but it means the count of things that cannot happen is smaller than the schema reads.

Anthropic’s strict tool use documentation carries a second constraint that is easier to miss and worse to discover late. It says schemas are compiled into grammars and “temporarily cached for up to 24 hours since last use,” and that while strict tool use is HIPAA eligible, PHI must not appear in tool schema definitions, not in property names, not in enum values, not in const values, not in pattern regular expressions. The cached schema does not receive the protections that prompts and responses do.

That is a cross-layer constraint worth carrying. The schema is the natural place to put a tidy enum of plan names, or a regex for a member identifier, or a property called diagnosis_code, and it is the one part of the request that leaves the protected path, so schemas here carry shapes and calendar years and nothing that names a person, which cost one refactor to get right, since the first version encoded plan names in the enum. The gate itself is deliberately dull, and its dullness is the point. One naming note before the code, because the first version got it wrong: the classification on each tool is not is-this-PHI, since enrollment status is protected health information whether or not the agent is allowed to say it, and what the carve-out changes is who it may be disclosed to, so the two values are the ones the regulation actually distinguishes and the branch that escalates reads BEYOND_CARVE_OUT.

def decide(tool_name: str, args: dict, who: Requester) -> Decision:
    """Decide before the call, not after.

    Reads: this duplicates a check Workday will run again on the domain security
    policy, and exists so a denial never reaches the model's context.
    Writes: this does not duplicate the business process security policy, which
    evaluates the initiator per step and is invisible from here.

    args is model output. It carries the subject and the payload, and no
    security predicate below reads it: the process comes from a table keyed by
    tool name, and the enrollment window comes from who, which the session
    builds before the model is ever called.
    """
    if tool_name not in TOOL_DISCLOSURE:
        return Decision("refuse", f"no tool named {tool_name}")
    if args.get("worker_id") != who.worker_id:
        return Decision("refuse", "subject is not the requester")
    if TOOL_DISCLOSURE[tool_name] is Disclosure.BEYOND_CARVE_OUT:
        return Decision("escalate", args.get("reason", "unclassified"))
    if tool_name in WRITE_TOOLS:
        process = WRITE_TOOLS[tool_name]
        if process not in INITIABLE:
            return Decision("escalate", f"{process} is not on the initiator list")
        if process == "Change_Benefit_Elections" and not who.enrollment_window_open:
            return Decision("escalate", "outside an open enrollment window")
        return Decision("call", f"initiating {process}")
    return Decision("call", "inside the enrollment-status and employment scope")

The docstring carries the finding, so read it before the code. On reads, this function is a duplicate control. Workday evaluates the domain security policy on every call regardless, and the pre-check exists so a denial never becomes text in the model’s context where it can be narrated around. But on writes it is not a duplicate of anything, because a business process security policy evaluates the initiator’s permission at each step of the process, and those step definitions are not visible to the caller. A call that passes this function can still be refused by the process. So the same short function is redundant defence in one direction and the only thing standing there in the other, and I would rather write that down than let someone infer symmetry from the shape.

The third paragraph of that docstring is the one that cost me the most, and I only wrote it after a review comment. An earlier version of this function read the process name out of args, and for benefit elections read a boolean called in_enrollment_window out of args as well. Both branches looked like checks and neither was one, because args is model output. Strict tool use guarantees that a boolean arrives as a boolean; it says nothing about which boolean, and the value is the sampler’s to choose. So a call carrying Change_Benefit_Elections with in_enrollment_window set true in March would have been authorized by the one function whose job was to refuse it, and whether a window is open is a fact about the tenant’s benefit calendar that the model has no access to and no reason to know. That is the plan_year argument from twenty lines earlier, committed by me, inside the code written to enforce it. The fix is four lines: the tool name selects the process through WRITE_TOOLS, and the window becomes a field on who, resolved from the benefit group’s enrollment calendar at session build. The rule I would put on a wall is that args may carry a subject and a payload, and every predicate a decision turns on comes from the session or from a table. The model’s reason code still goes into the log, since a label is not a predicate.

One more line deserves its own defence. The subject check refuses when args["worker_id"] is not the requester’s, and there is no delegation path around it. A manager asking about a direct report goes through the manager self-service channel, which is a different deployment with a different ISU and a different eligible set, because the moment one agent can read two people’s records under one grant, the union problem from the first section becomes unbounded.

What a write actually sends

Everything above is a read or a refusal, which is the flattering half of the system. Of the four processes the write ISU may initiate, home contact information is the one that runs at volume, because a person who moves house knows their own new address and nobody needs to adjudicate it. Here is the whole envelope, and the interesting part is not the address.

<soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/"
                  xmlns:bsvc="urn:com.workday/bsvc">
  <soapenv:Body>
    <bsvc:Change_Home_Contact_Information_Request bsvc:version="v46.2">
      <bsvc:Business_Process_Parameters>
        <bsvc:Auto_Complete>false</bsvc:Auto_Complete>
        <bsvc:Run_Now>false</bsvc:Run_Now>
        <bsvc:Discard_On_Exit_Validation_Error>true</bsvc:Discard_On_Exit_Validation_Error>
        <bsvc:Comment_Data>
          <bsvc:Comment>Initiated by the employee-services assistant, session 7f3c1a</bsvc:Comment>
        </bsvc:Comment_Data>
      </bsvc:Business_Process_Parameters>
      <bsvc:Change_Home_Contact_Information_Data>
        <bsvc:Person_Reference>
          <bsvc:ID bsvc:type="Employee_ID">100241</bsvc:ID>
        </bsvc:Person_Reference>
        <!-- the address carries its own Effective_Date attribute and loses to
             this element whenever the two disagree, so only this one is set -->
        <bsvc:Event_Effective_Date>2026-07-17</bsvc:Event_Effective_Date>
        <bsvc:Person_Contact_Information_Data>
          <bsvc:Person_Address_Information_Data>
            <bsvc:Address_Information_Data>
              <bsvc:Address_Data>
                <bsvc:Country_Reference>
                  <bsvc:ID bsvc:type="ISO_3166-1_Alpha-3_Code">USA</bsvc:ID>
                </bsvc:Country_Reference>
                <bsvc:Country_Region_Reference>
                  <bsvc:ID bsvc:type="Country_Region_ID">USA-MA</bsvc:ID>
                </bsvc:Country_Region_Reference>
                <bsvc:Address_Line_Data bsvc:Type="ADDRESS_LINE_1">118 Winter Street</bsvc:Address_Line_Data>
                <bsvc:Postal_Code>01604</bsvc:Postal_Code>
                <bsvc:Municipality>Worcester</bsvc:Municipality>
              </bsvc:Address_Data>
              <bsvc:Usage_Data bsvc:Public="false">
                <bsvc:Type_Data bsvc:Primary="true">
                  <bsvc:Type_Reference>
                    <bsvc:ID bsvc:type="Communication_Usage_Type_ID">HOME</bsvc:ID>
                  </bsvc:Type_Reference>
                </bsvc:Type_Data>
              </bsvc:Usage_Data>
            </bsvc:Address_Information_Data>
          </bsvc:Person_Address_Information_Data>
        </bsvc:Person_Contact_Information_Data>
      </bsvc:Change_Home_Contact_Information_Data>
    </bsvc:Change_Home_Contact_Information_Request>
  </soapenv:Body>
</soapenv:Envelope>

Business_Process_Parameters is where a write stops being a data change and becomes a governance decision, and the WSDL annotation on Business_Process_ParametersType is unusually forthcoming about it. Submit no options, or submit them all as false, and the process is “simply initiated as if it where submitted on-line with approvals, reviews, notifications and to-do’s in place,” typo included. That default is the right one and it is the reason the block is written out explicitly instead of omitted: a reader of this code should be able to see that nothing was switched off.

Then the sentence that made me stop and re-read it. The same annotation ends: “If the Initiator is an Integration System User, any validations you configured on the Initiation step are ignored.” The tenant’s own configured validations on the initiation step do not run for the account this whole post is about, and the reason they do not run is the identity, not the payload. Everything I had been arguing from the security side, that minting an account moves the per-request decision to your side of the wire, turns out to be documented in one clause of one xsd:documentation element, as a processing note. That is where I would send a sceptical architect first.

The two booleans are worth separating, because they look like a pair and they are not. Auto_Complete means, in the WSDL’s words, that “all approvals will be automatically approved in the system, all reviews and to-do’s will be automatically by-passed, and all notifications will be automatically suppressed.” It is false on every process this account may initiate. But it is the one flag I would put a test around, because setting it converts a write into an approval, and that is not a conversion any tool input should be able to perform. Run_Now is the performance flag: it “indicates the transaction should process to completion before the response is generated,” and it is false because the SLA in the next section is eight seconds and a business process running to completion is not an eight-second operation. (The WSDL’s own note on the flag asks you to review the setting in your environment for performance conditions, which is a polite way of saying it.)

That choice has a consequence the response type spells out. The whole of Change_Home_Contact_Information_ResponseType is a single optional element, Change_Home_Contact_Information_Event_Reference, documented as “the Home Contact Change Event created by the corresponding request.” A 200 means an event exists. It does not mean the address changed, and with Run_Now false it usually has not yet. This is the Foundry sentence from later in the post arriving from the other direction, a write whose success you cannot read back, and it is the same incident as the stale materialized copy: a worker asks whether it saved and the honest answer is a poll, not a status code. So the write wrapper holds the event reference and answers “did it save” from the event, never from the HTTP response.

Discard_On_Exit_Validation_Error is set true because its documentation says it “enforces all critical validation conditions defined on the initiation step,” which is the class of check the ISU-initiator clause switches off. Reading the two annotations together, one flag in the container appears to hand back what the identity took away; I couldn’t find a page that states that interaction, so it is my reading and the tenant test is the evidence, not the docs. Either way the default is off, it is one line, and it is invisible to anyone who does not read the type.

The rest of the design falls out of the failure messages the WSDL declares. Change_Home_Contact_Information_Business_Process_DataType carries a validation that fires when “there are other pending or completed actions for the person that conflict with this one,” so a worker with a contact-information event already in flight gets a refusal instead of a queue position, and the tool has to say so in words a person will accept. Another one refuses an effective date earlier than the latest address event and tells you to rescind that event first, which is not a process on the initiator list and never will be. So the write tool’s input schema carries the address fields and the subject and nothing else; the effective date is today, set server-side, for the same reason the enrollment window is. And Replace_All, an attribute whose own documentation says a true value means “all non-primary addresses will be deleted,” is left off deliberately. One boolean should not be able to do that on a turn the model chose.

The read that fits the budget belongs to someone else

The SLA the client already published for in-channel answers was a p95 of eight seconds, which nobody thought to renegotiate because it predated the project. Eight seconds is generous for a model turn, but it is hostile for an HCM read.

Report-as-a-Service is the tempting path. A custom report exposed as a web service returns joined, calculated, security-filtered rows in a single call, in several formats including JSON, addressable at a namespaced report URL that survives tenant migration. The reason to want it is not call count, which is where I had the argument wrong for a while. Worker_Response_GroupType carries sixty include flags, three of them Include_Benefit_Enrollments, Include_Benefit_Eligibility and Include_Related_Persons, so elections and dependents and coverage dates are already one Get_Workers call with three booleans flipped. What SOAP will not give you is a calculated field: a coverage end date derived from an event date and a plan rule, an eligibility flag that is a condition over three object families, a joined row that exists only in a report definition. RaaS returns the answer; the response group returns the parts and leaves the arithmetic to you. But a live RaaS call re-executes the report definition, and a report definition is a batch artifact designed to be run on a schedule, by an integration, over a population.

Measured over a week, 400 calls per path:

Read-path latency on a log scale from p50 to p95: the pre-materialized cached copy runs 0.09 to 0.4 seconds, a single-worker SOAP call 1.1 to 3.2 seconds, a worker-prompted RaaS report 1.9 to 6.4 seconds, a hundred-worker SOAP page 8.7 to 19.3 seconds against an 8 second budget, and the full-population report refresh takes 74 seconds in a single run

A worker-prompted RaaS report at a p95 of 6.4 seconds leaves 1.6 seconds for everything else, which no model turn fits inside. So the report gets pre-materialized. A scheduled run writes the full population to an object store every fifteen minutes, and the agent reads a key. That path runs 0.09 to 0.4 seconds and 82% of reads take it, which is the entire reason the end-to-end p95 lands at 7.8 seconds instead of well past the SLA.

Those two figures don’t compose, and it is worth saying so before a capacity review tries. The 6.4 seconds is the 95th percentile of the live path measured on its own, and the live path is 18% of reads, so it contributes 18% of the mass to a mixture whose own 95th percentile is 7.8 seconds. A component’s tail percentile tells you nothing about the same percentile of the blend; percentiles do not add and they do not average. So 7.8 is measured end to end, at a mean of 2.4 model turns a case, and there is no arithmetic in this section that produces it.

Then notice what just happened to ownership. The refresh interval is set in the tenant by the integrations team, the full-population run takes 74 seconds, so the worst-case age of an answer is 16.2 minutes, and both the agent’s latency and the agent’s truthfulness are now properties of a schedule owned by a group that has never heard of the SLA and has its own reasons to move it. The incident that teaches you this is a worker who changes their address, asks the assistant to confirm it saved, and is told no. The fix is a freshness policy at field granularity: anything a worker could have changed in the last quarter hour reads live and pays the latency, which means home contact information, time-off balances after a submission, and enrollment status inside an open enrollment window, while everything else reads the copy. That split is the 18% of live reads, and it isn’t a tuning parameter, it is a list somebody maintains. I have never seen that list get shorter.

There is a second thing worth knowing about the report path, which is the reason it is not simply the answer to everything. A report evaluates security in the context of the account that runs it. A report built years ago for a payroll integration, scoped to a population and a field list nobody has revisited, will happily return that population and those fields to your agent, and nothing in the agent changed. Reusing an existing report is the fastest way to widen an agent’s reach without a single line of code or a single access-review ticket. Every report the fleet reads is one we built, own, and named after the agent that reads it.

The refresh job itself came out of the existing estate, and that is worth being explicit about because it is where the deployment touches the tenant’s existing integration estate. The client already ran a shelf of Workday Studio orchestrations doing nightly work, built in the Eclipse-based tooling by people who left years ago, carrying monitoring the operations team already watches. A new refresh path would have meant a new integration event, a new ISU, and a new thing for that team to learn, so the materialization runs inside an existing orchestration. That reuse is cheap, yet it buys a failure mode. When a Studio run fails, the copy does not error, it simply stops moving, and stale data looks exactly like fresh data to a reader. So the materialized record carries the run timestamp, and the read wrapper refuses freshness-sensitive questions when that timestamp is older than the interval plus one run. The version string in the SOAP envelope has the same character. Workday’s web services directory sits at v46.2 for the 2026R1 release and the platform ships two major releases a year, so bsvc:version ages on a calendar nobody on the project controls. It sits in configuration, and moving it is on the same checklist as the tenant’s release weekend.

The metric that scored giving up as a win

The client had a self-service resolution rate on a dashboard before I arrived, and it read 52.4%. The definition was a session in the assistant with no case created within 24 hours, and I didn’t question it for a quarter, which is longer than I would like. The problem is what a person does when an assistant does not help them: they close the tab and call the service desk, which is a channel switch and not a resolution, and the 24-hour window with a per-channel denominator cannot see it. So I joined deflected sessions against phone and walk-up contacts within seven days, matched on topic, and then, because a topic match is a weak instrument, read 200 of the matched pairs by hand.

704 of 4,140 deflected sessions had a topic-matched contact in another channel within a week, 17.0%. The denominator under all of this is 7,900 sessions, which is a steady-state month and not an average of twelve; November’s open enrollment carries three times December’s case volume, and a before-and-after comparison that straddles it measures the calendar. So every month quoted in this section sits outside the enrollment window, which is a restriction on what the finding covers and not a rounding convenience. Of the 200 pairs read, 148 were the same unresolved question and the rest were new questions from the same person. That puts the corrected resolution rate at 45.8%, with 43.5% as the floor if you count every match as a failure. The read rate carries its own binomial interval of about six points at n=200, which moves the corrected figure between 45.3% and 46.4%, so it is one decimal place of confidence and no more.

A 6.6 point gap isn’t a scandal. What matters is the direction: the metric was biased in the flattering direction, by construction, and the bias grows exactly when the assistant is worst, because a worse assistant produces more silent abandonment and abandonment scores as success.

Two numbers in this post are in obvious tension and it is worth spending a paragraph on why they are not, because a steering committee will do the subtraction. The funnel counts questions: 92,100 in scope a year, 58,944 of them answered end to end, which is 64% containment per question. The dashboard counts sessions: 7,900 a month, 52.4% of them closing with no case, corrected to 45.8%. Those are different denominators and neither is a correction of the other. 58,944 answered questions a year is 0.62 per session against 0.97 in-scope questions per session overall, so the typical session carries one in-scope question or none, and the two-question session is a minority. It is the minority that moves the rate. A worker who gets their pay date and then asks about their coverage produces one session in which one question was answered and the other escalated; that session opens a case, scores as a failure on the dashboard, and still contributes an answered question to the funnel. So the two rates can move in opposite directions and both be right, and averaging them produces a number that measures nothing. The per-question figure is the one to compare against a replay set, the per-session figure is the one to compare against last quarter, and I have watched a slide put them side by side with a single axis label.

Then the part I would have preferred not to publish. The obvious remedy is to make giving up easy and visible, so we put an explicit escalate control at the top of every answer instead of at the bottom.

Monthly assistant sessions before and after adding an explicit escalate control: reported deflection falls from 52.4 percent to 49.1 percent while corrected resolution stays at 45.8 and 45.9 percent, and 7-day cross-channel re-contact among deflected sessions falls from 17.0 percent to 9.2 percent

Reported deflection fell 3.3 points, from 52.4% to 49.1%. Cross-channel re-contact nearly halved, from 17.0% to 9.2%. Corrected resolution went from 45.8% to 45.9%, which is nothing. Those two are not one formula run twice: the after period got its own hand read of 200 matched pairs and came back at 71%, 142 of 200, against 74% before, so the second figure is 3,879 clean sessions less 0.71 of 357 re-contacts. Re-reading was the right call and it cost the finding nothing, since a tenth of a point is not a result in either direction. The change did not help more people, but it made the dashboard tell the truth about the people it was already failing, and it moved the reported number in the direction that looks like a regression. Presenting that to a steering committee is its own skill, and the thing that made it survivable was having introduced the corrected metric a quarter earlier, before it was convenient.

While I am reporting things that did nothing: we tested whether giving the agent a second, broader job-data read tool would raise containment, on a 240-case replay set. Without it, 62.9%. With it, 61.7%. Three cases, against a run-to-run spread of 1.8 points on the same set across five runs and a binomial standard error of 3.1 points at that base rate. That is noise, and the tool was removed, though the argument for removal never depended on the eval. The tool widened the read ISU’s domain grant, and a domain grant is not a code change; it goes into the next quarterly access review. Across the six such requests I have watched, the median from request to effective was 41 days.

The same seam in four other systems of record

Four other systems of record answer this question, in four vocabularies that disguise how similar the shapes are. The question to carry into each set of security documentation is the same one the security administrator asked in week one: when an agent acts, whose authorization is evaluated?

The asking person's authorization survives only while that person is waiting, and four of the five platforms document the service identity that replaces it: Workday the worker's session versus an integration system user, Agentforce the logged-in user versus the agent-assigned user, Now Assist the triggering user versus a fixed-role AI user, Foundry the submitting user versus a service user, and for Joule the fleet identity is not documented; grant units are security domain and business process, object and field, role and ACL, business role or classic authorization object, and action type, and only Foundry's is purely an operation

With a person at the keyboard they all answer well. SAP’s Joule is bound to the invoking user’s own authorization, and the cockpit documentation puts it as plainly as anyone does: Joule “can only accomplish tasks for you that you’re also authorized to perform.” Palantir’s Ontology is the most precise of the lot, evaluating action type permissions on the user submitting the action, who must be able to view the edited object types and their datasources and pass the action’s submission criteria, and criteria can depend on parameter values.

Salesforce is where the question has the most literal answer I found anywhere, because it is a field in a request body. The Start Session call in the Agent API takes a bypassUser parameter, and the documentation on it reads: it “indicates whether to use the agent-assigned user instead of the logged in user. If set to true, the API uses the user associated with the agent. If set to false, the API uses the user associated with the token.” One boolean, two authorization models. The identity on the other side of that switch is named in configuration too, since enabling the client-credentials flow on an external client app asks you to “Set the Run As (Username) field to the email address of a user that has at least API Only access.” So whose authorization is evaluated is not a property of the agent’s design or of what the agent is for. It is a boolean on a request and a username on a policy tab, and neither one appears in the conversation or in the transcript a reviewer reads afterwards.

ServiceNow has thought about this harder than anyone else here, and it is the strongest counter-argument to everything above, so it is worth stating at full strength. Role masking “limits the roles with which an agentic workflow, AI agent or Skill can execute to the intersection between the roles assigned to the invoking user and the roles included in the role masking approved roles list.” That is a native least privilege dial for an agent, the thing I said the vocabulary did not have. It also comes with an evaluation order six steps deep: workflow ACLs against the invoking user’s roles, then workflow role masking, then agent ACLs against the workflow’s approved roles, then agent role masking, then tool ACLs, then the tool role mask for Skills. A chain of custody for permission across a multi-agent call, written down and shipped.

Now read the conditions on it. Role masking “cannot be configured for agentic workflows or AI agents set to run as AI users,” and an AI user is “a dedicated user identity” with “assigned roles that remain consistent regardless of who or how the execution is invoked,” under which “the execution will succeed even when invoked by a user with lower privileges.” It is a record on the user table, and what is worth holding on to for later is that ServiceNow ships two different answers side by side: role masking, which carries the invoking user into an intersection, and the AI user, which is an identity with roles of its own and no intersection at all. Dynamic user “is the default user identity,” but a default is a starting position and not a property of the platform. The approved-roles list has an off switch of its own, a checkbox on the role-masking record that “deactivates the requirement for a role masking approved roles list,” carrying the vendor’s own note that overriding it is not recommended. And on the page that defines the AI user, the AI ACL that decides who may invoke an agent does not follow it inward, because those ACLs “only determine the roles required for users to invoke an agentic workflow or an AI agent. They don’t determine the access that the agentic workflow or an AI agent has once it’s invoked.” An invocation gate and an execution identity read like defence in depth, and they are two controls over two different things.

The place that decides it is the automated trigger. An agent fired on a schedule or by a record change has no conversational user, and ServiceNow documents that role masking still applies while the invoking context is a system session. Intersecting an approved list with the roles of a system session is not least privilege for a person’s request, because there is no person in the intersection.

SAP is worth getting right because the vocabulary is really two vocabularies. Classic ABAP authorizations are authorization objects, each grouping up to ten fields related by AND. S/4HANA Cloud’s identity and access management is business roles assembled from business catalogs and narrowed by restriction types and restriction fields. The two are layered rather than parallel, and SAP’s own ABAP Cloud guidance states the direction: for each authorization field you want to expose and maintain in a business role, you need to create a corresponding restriction field and assign it to a restriction type. What I take from that, and it is a reading rather than a rule the page states, is that a restriction field exists only where someone has already done that work, and the someone is a developer in ADT or SAP shipping a standard object, never a tenant administrator opening a role editor. Worth verifying in your own system before quoting it out of mine. Pairing the two nouns in one sentence sounds precise and is usually a tell that the writer has read neither model closely.

The Joule sentence sits on a third surface again, which is the part I keep having to re-explain. It is on the BTP cockpit page, and cockpit authorization is not the same object as an S/4HANA business role or a classic authorization object. So the SAP row in the chart above names both grant units instead of choosing one, and its answer to whose authorization is evaluated depends on which model the estate in front of you runs. That is a question about the customer, not about Joule.

Foundry is worth dwelling on because it is the one platform whose grant unit is not a kind of data. An action type is a business operation with its own parameters and its own submission criteria, which is exactly the granularity an agent needs, and an Action tool in AIP Chatbot Studio “gives your chatbot the ability to execute an ontology edit,” configured “to run automatically or to run after confirmation from the user.” Approval is a property of the tool rather than something the orchestrator bolts on. Foundry also separates mandatory from discretionary controls: markings restrict access regardless of role, and removing one takes a permission on the marking itself that owning the resource does not confer. The same permissions page carries the sharpest sentence in the whole reading list, though, and it is a warning rather than a feature. Where an object type only allows edits through actions, the submitting user needs only read access on the objects being edited, so “it is possible for users to create objects that they cannot view.” An agent that writes and cannot read back what it wrote will report success it cannot check.

Now the column labelled run by a fleet. A fleet is not a person clicking. It is a service that answers thousands of sessions and runs scheduled work, and four of these five platforms name in their own documentation the identity that service authenticates as. SAP’s cell in the chart reads not documented, because I couldn’t find a page stating what a scheduled Joule action runs as; a cell that admits that is worth more to a reader than a plausible one. Foundry says it as plainly as Workday does. Its client credentials documentation states that by default the service account “does not have access to any resources,” which is the good default, and then: “You must implement authorization logic in your application since users cannot log in individually.” That is the same sentence as Workday’s security exception, from the opposite end of the enterprise software market, and neither vendor is being careless. They are both telling you that the per-request authorization decision has left their building.

Integration middleware repays the same reading, and reading it carefully matters more than the marketing suggests. MuleSoft ships MCP servers, and the phrase “permission-scoped” appears once in that documentation set, on the server that answers an AI assistant inside a developer’s IDE. The server that exposes the platform itself is described as governed access instead, and the connected app the setup instructions point at is the kind that acts on its own behalf. What is scoped there is the app’s OAuth scopes, not the developer’s identity, so a gateway holding one credential for many callers is the union problem again, one layer further from the data and from anyone who can read a plan document.

The line across all five is not interactive against API, which is how I had it filed for most of a year. It is whether a person is waiting. Each of these platforms can carry the asking person’s authorization into a call that person is sitting in front of, and four of them are documented as handing the work to an identity of their own as soon as a schedule or an event starts it, which is most of what a fleet does.

The delegation primitive shipped in an admin guide

The build-versus-buy question came up at the start and it came up again at every renewal, which is the right cadence, because the answer moves underneath you.

Every system of record here now ships a sanctioned AI feature. Workday has an Agent System of Record for registering and governing agents. Salesforce has Agentforce and the Einstein Trust Layer, which handles masking, zero-retention agreements, toxicity detection, grounding and an audit trail. ServiceNow has Now Assist. SAP has Joule. Palantir has AIP. The naive assessment compares answer quality on a sample of tickets, and it is the wrong assessment, because answer quality converges and does so quickly.

The names churn faster than the models do, which is its own warning about planning against them. The Illuminate branding that covered Workday’s AI work when this project started is gone from those pages, replaced by Sana, and the only trace of it left on them is a CSS class called illuminateCrop.

The assessment that predicted anything was narrower. Has the vendor added a first-class agent principal to its permission model, or is it still a user record with a different label? That primitive is the one thing a customer cannot build. I can build routing, evaluation, escalation, an audit trail, a disclosure gate, a freshness policy. I can’t add a noun to the tenant’s security model.

One of the vendors in the last section answers it with an identity and one answers it with a delegation, and I had those filed together for longer than I should have. Salesforce’s agent-assigned user is an identity: a user record, nameable in the tenant, carrying no trace of who the agent is acting for. ServiceNow’s AI user is the same shape, literally a row on the user table. But role masking is not that shape at all. Intersecting the invoking user’s roles with an approved list is structurally the same operation Workday’s delegate mode performs when it intersects the user’s permissions with the agent’s allowed skills, and it does carry who the agent acts for, which is the whole point of an intersection. So the honest comparison is not identity against delegation; it is two delegations, one of which is missing three things.

Set delegate-mode ASU against role masking and the three are a token exchange, a credential with an expiry, and dual audit attribution. Role masking is a role-resolution rule applied inside a session, so there is no artifact that represents the delegation, nothing to revoke, nothing that runs out, and nothing that tells an auditor six months later that the agent acted and a person authorized it. The On-Behalf-Of exchange produces a token, the token is short-lived, and the audit row names the agent as the acting user and the person as the on-behalf-of user. Those three are what make a delegation reviewable rather than merely correct at the moment it ran, and they are also the three a customer cannot add from outside.

The platform that has all three is the one this post is about, and it wrote them down in a place nobody announced. Workday’s agent security page, which is in the administrator guide and needs a customer login to open, describes an Agent System User. It is introduced the way you would hope, as something similar to an Integration System User, except that each agent has a unique identity, and an agent can hold up to two of them, one for delegate execution and one for ambient execution, each with its own OAuth 2.0 client. Delegate mode is acting on behalf of a user, implemented as an OAuth 2.0 On-Behalf-Of exchange that trades the user’s token for a delegated access token, and authorization in that mode is the intersection of the user’s permissions and the agent’s allowed skills. Read the lifetimes on that page more carefully than I did the first time, because I repeated them wrong in a design review. The 60 minutes for a first-party agent and four hours for a third-party one are stated against the Authorization Code grant, and the page publishes no lifetime for the delegated access token itself. An access token expires whatever the number turns out to be; a number you haven’t actually located is not a number to put in a threat model. Scoped to a set of operations, bound to a named person, short-lived, and legible afterwards to someone who has to explain it, with the third of those four inherited from how OAuth works instead of stated on the page: that is the primitive, and it is in a tenant near you.

Read the other half of the same page, though, and the shape of this whole post comes back. Ambient mode, the account an agent uses when an event or a schedule starts it rather than a person, is evaluated solely against the agent’s own permissions. No user token to exchange, no intersection, nobody to be on behalf of. That is precisely where ServiceNow’s automated triggers landed, and it isn’t a coincidence: an on-behalf-of exchange needs a behalf, and a cron job does not have one, so both vendors carry the person for exactly as long as a person is waiting. Two further details before anyone plans around this. Third-party agents cannot customize token expiry yet. And Workday states that it cannot detect compromised credentials on your behalf, which puts that control back on the customer side, where it has been all along.

Then there is the announcement, and getting the referent right matters here more than usual, because two announcements went out on the same day in June 2026 and the memorable phrase is not in the one people attach it to. The Agent Passport release is the one to read: early access in the second half of the year, general availability projected before the end of it, and content that turns out to be attestations of safety testing against the OWASP top ten for LLM applications, the NIST AI risk management framework and MITRE ATLAS, plus a runtime monitor that allows, blocks or routes. Search that release for delegate, credential, permission, scope and expiry and the count is zero for all five. (The line about giving agents digital stamps, which is what most write-ups quoted, is from the companion developer-tools release published the same day, and the stamps there are security-testing stamps.) There is no documentation behind Agent Passport yet, and the product name appears in neither the documentation site’s search index nor its sitemap. So the feature with the identity-shaped name is an attestation record, and the delegation primitive is a kind of user account described in an administrator’s guide. The protocol story has the same shape: MCP and A2A appear in the press releases, and a full-text search of the product documentation returns zero hits for A2A and two for MCP, one of which is a German page about merchant category codes. What has shipped and is documented is Agent Gateway: REST, SOAP, Graph, WQL and RaaS over OAuth 2.0, behind a required tenant-alias header, and third-party agents must route their API traffic through it under Agent System User security.

One more asymmetry, and it takes about thirty seconds to check. The feature that hasn’t shipped, has no documentation and carries no delegation semantics is announced on a newsroom page anyone can read, while the delegation model that has shipped, is specified down to token lifetimes and audit rows, and is the thing a security architect actually needs, sits behind a customer sign-in. What a prospective buyer can verify about a vendor’s agent security is the marketing. What is true about it requires an account.

So the criterion I take into a renewal is now five questions, and not one of them can be answered from a press release.

  1. Is there an identity per agent, or one account for the fleet?
  2. When a person asks, does the call carry that person, and by what mechanism? A token exchange and a role intersection are both real answers, and a platform can have the second without the first.
  3. Does the mechanism leave an artifact that expires and can be revoked, or is it a rule evaluated inside a session and gone? If it is a credential, who sets the lifetime, and does that answer change for a third-party agent?
  4. On the scheduled path, where no person is waiting, what is authorization evaluated against?
  5. Does the audit record name the agent and the person separately, and can the privacy officer read it without an engineer?

Question four is the one that decides how much code you keep. The deployment in this post runs on Integration System Users, because that is what existed when it was built. Rebuilt today, the interactive read path would be an ASU in delegate mode, and the subject check in that dull little gate would become an assertion that the intersection did what the documentation says. The materialization job that runs every fifteen minutes would be ambient, and everything this post says about unions and coarse grants would apply to it unchanged.

The identity industry is standardizing the same shape, more slowly and in public. An IETF Internet-Draft from Okta authors extends SCIM with an /Agents resource type under the schema URN urn:ietf:params:scim:schemas:core:2.0:Agent, alongside /AgenticApplications, so that agents can be provisioned and deprovisioned the way accounts are. Another proposes an OAuth grant for agent delegation, on the stated grounds that the authorization code and client credentials grants “do not fully address the nuances of agent delegation where explicit user consent for a specific agent’s action is required.” Drafts expire and vendors ship first, but they are useful as a vocabulary: when a salesperson says agent identity, these are the words to ask them to use instead.

Two practical consequences follow. Write the per-request authorization as a component with a name and a test suite, so that when the primitive reaches your tenant you can delete a file instead of unpicking a philosophy. And put the five questions on the renewal calendar with the date you last checked each one, because the answers moved twice while this post was being written.

What I would keep

The model spend on this, across 58,944 answered questions and the escalations and refusals around them, was about $3,700 a year, roughly 1% of the tier-one handle cost it displaced, but that ratio is the least interesting true thing about the project. The cost was the security review, the report ownership, the roster, the freshness list, and the quarter I spent measuring a number that was already wrong.

Median time to resolve for cases a human still works, before and after: policy 4.0 to 2.9 hours, payroll 5.2 to 4.2, job and org data 8.0 to 6.9, onboarding rising 11.3 to 12.1, benefits rising 19.4 to 22.1, and leave 26.2 to 25.7 which is inside its bootstrap interval

Three of the six queues came down by about an hour, and the constancy is the thing to read: policy, payroll and job data all improved by roughly the same amount because they sit in one queue, so what improved was the waiting and not the work. Two queues went the other way. The benefits median rose from 19.4 hours to 22.1, which is not a regression, because the agent took the short questions out of that queue and what remains is harder by construction. Onboarding rose by less than an hour for the same reason. Leave moved from 26.2 to 25.7 on a bootstrap interval running from minus 2.4 to plus 1.4 hours, so I report it as flat rather than as a small win, and the reason it is flat is the only interesting thing about it. Leave is 11% eligible, so almost nothing left that queue and there is no composition shift to push the median up; the shared hour of wait relief that moved the other three is real for leave too and simply does not show against a 26-hour median whose own spread is measured in days. A one-hour effect on a queue like that needs more than a quarter of arrivals to separate from noise. Any team quoting a queue median across a changing mix should say which of the two effects it is looking at. The number I would put on a slide instead is the escalated median, 3.1 hours against 6.0 hours for the same mix of cases before the agent existed, because escalation carries a prepared case with the worker’s own words, the enrollment facts already fetched, and the reason code. The gate that exists for disclosure reasons is also the thing that made the humans faster, and that is the one place in this project where the safety control and the primary metric pulled the same direction.

If I were handed the next one of these, the first week would be meetings and no code. Sit with the security administrator and write down, in the tenant’s own vocabulary, what the agent is: the account, the groups, the domains, the processes, the union, and the check that has to exist because the union is coarse. Then sit with whoever owns the privacy program and find the paragraph that decides which questions are answerable at all, because there is always a paragraph, and finding it in week one is the difference between a scope and an argument.

The agent is not a worker and it is not a job. Until the tenant has a word for what it is, that word is code you wrote and that someone will have to maintain after you go.


Share this post:

Previous Post
A confidence score is not a reason
Next Post
Nobody owns the join between two agent platforms