Most companies already know how to onboard a person.
There's a start date. A manager. A laptop. A list of apps.
There's also a last day. Access is supposed to end there.
Agents are joining the same companies now. They need email, the CRM, a repo, sometimes finance.
Here's the part I keep thinking about.
Nobody files a ticket for a bot's last day.
On October 13, 2026, Workday announced it has "signed a definitive agreement to acquire C1.ai, a leading agentic identity platform for people and agents." Workday says C1.ai connects to the systems a business runs on, "from email and CRM to code repositories, finance systems, and cloud infrastructure."
The release puts the agent part plainly:
Once registered, an agent can receive the access it needs, when it needs it, and lose that access immediately when its work or role ends.
C1.ai would build on the Workday Agent System of Record. Workday says that record "already gives companies one place to see and manage their agents."
C1.ai would go "beyond registering agents to also governing what they can access."
One more line from the release stuck with me:
An agent that was retired last quarter shouldn't still be able to get in
The deal hasn't closed. Workday expects it to close in the fourth quarter of its fiscal year 2027, which ends January 31, 2027, subject to customary closing conditions.
The same day, Workday made its Trusted Agent Platform headless. "Headless can't mean lawless," said CTO Gabe Monroy.
It also announced a partnership with Anthropic where Claude and Sana agents talk over MCP, and "every action follows the customer's existing permissions and audit controls in Workday."
Three announcements. Same direction.
The agent's access should follow the agent's lifecycle.
I've been waiting for this one. On September 19, in The Computer Is Becoming an API for AI, I wrote a short timeline. One line said:
2027: Agents get identities. Dedicated accounts, credentials, budgets, permissions and audit trails become normal.
A signed deal is not "normal" yet. But it's the right direction.
It's also a question I run into building Roster, where AI employees get real tools and real access.
So let's build a tiny version.
By the end, you'll run one command:
npx tsx access-grant.ts
And watch an agent get registered, get scoped grants, get denied above its role, lose finance on a role change, hit an expired grant, and lose everything the moment it ends.
This is my small model of the idea. It is not how Workday or C1.ai work inside.
The agents, roles, systems, scopes and times are example inputs. Everything runs in memory with a fake clock.
Table of Contents
- The Problem
- Step 1: An Agent System of Record
- Step 2: Grants That Live Inside the Role
- Step 3: Check on Every Action
- Step 4: Lifecycle Events Move Access
- Step 5: The Ticket Version
- Step 6: Run It
- Where It Breaks Down
- The Bigger Idea
1. The Problem
Most access today is a pile of separate decisions.
Someone files a ticket. Someone approves it. An app gets a token.
Then the work ends. The agent gets marked as retired somewhere.
And the token keeps working.
The C1.ai release frames the question well: organizations "need to know who and what has access, why they have it, and when to remove it."
Three questions. Who, why, and when.
The first one is the easy one. The other two are where it gets hard.
Workday's answer is to tie access to lifecycle events. In its words, "every new hire, job change, and exit is a Workday event."
So in our tiny version, access moves with three events:
- The agent is registered.
- The agent's role changes.
- The agent ends.
Plus one more: a grant can expire on its own.
A grant without a reason and an end is just a door left open.
2. Step 1: An Agent System of Record
Save the following TypeScript blocks in order as access-grant.ts.
First, the shapes.
// tiny-access-grant: lifecycle-bound access for registered AI agents.
// A small model of the idea. Not how Workday or C1.ai work inside.
// Agents, roles, systems, scopes and times are example inputs. No network. No API key.
type System = "email" | "crm" | "repo" | "finance" | "cloud";
type Status = "active" | "ended";
interface Agent {
id: string;
owner: string; // the person accountable for this agent
role: string;
status: Status;
}
interface Grant {
agentId: string;
system: System;
scopes: string[];
reason: string;
grantedAt: number; // minutes since midnight on a fake clock
expiresAt?: number;
revokedAt?: number;
revokeReason?: string;
}
interface Decision {
allow: boolean;
reason: string;
}
interface AuditEvent {
at: string;
kind: "register" | "grant" | "reject" | "check" | "revoke" | "role" | "end";
agentId: string;
detail: string;
}
// The most each role may hold. A grant can be smaller, never bigger.
const ROLE_CEILING: Record<string, Partial<Record<System, string[]>>> = {
"sales-assistant": { email: ["read", "send"], crm: ["read", "update"] },
"revops-analyst": { crm: ["read"], finance: ["read"] },
"support-triage": { email: ["read"], crm: ["read"] },
};
const hhmm = (t: number) =>
`${String(Math.floor(t / 60)).padStart(2, "0")}:${String(t % 60).padStart(2, "0")}`;
Every agent has an owner. That's the person accountable for it.
Every grant has a reason. That answers "why do they have it?"
expiresAt and revokedAt answer "when does it end?"
ROLE_CEILING is the most a role can ever hold. A sales assistant can send email and update the CRM. It never touches finance.
Now the record itself.
// Step 1: the agent system of record
interface Store {
now: number;
agents: Map<string, Agent>;
grants: Grant[];
audit: AuditEvent[];
}
function newStore(start = 9 * 60): Store {
return { now: start, agents: new Map(), grants: [], audit: [] };
}
function log(s: Store, kind: AuditEvent["kind"], agentId: string, detail: string): void {
s.audit.push({ at: hhmm(s.now), kind, agentId, detail });
}
function register(s: Store, id: string, owner: string, role: string): Agent {
if (s.agents.has(id)) throw new Error(`${id} is already registered`);
if (!ROLE_CEILING[role]) throw new Error(`unknown role ${role}`);
const agent: Agent = { id, owner, role, status: "active" };
s.agents.set(id, agent);
log(s, "register", id, `owner=${owner} role=${role}`);
return agent;
}
register is the only way in.
An agent that is not in the record can't get a grant. It can't pass a check either.
If it isn't registered, it doesn't exist.
3. Step 2: Grants That Live Inside the Role
// Step 2: grants that live inside the role
function isLive(g: Grant, now: number): boolean {
return g.revokedAt === undefined && (g.expiresAt === undefined || now < g.expiresAt);
}
function grant(
s: Store,
agentId: string,
system: System,
scopes: string[],
reason: string,
ttlMinutes?: number,
): Grant | undefined {
const agent = s.agents.get(agentId);
const ceiling = agent ? ROLE_CEILING[agent.role][system] ?? [] : [];
const extra = scopes.filter((x) => !ceiling.includes(x));
const why = !agent
? "not registered"
: agent.status !== "active"
? "agent has ended"
: !reason.trim()
? "no reason given"
: extra.length
? `${system}.${extra.join(",")} is above role ${agent.role}`
: "";
if (why) {
log(s, "reject", agentId, `${system} [${scopes}]: ${why}`);
return undefined;
}
for (const old of s.grants) {
if (old.agentId === agentId && old.system === system && isLive(old, s.now)) {
revoke(s, old, "replaced by a new grant");
}
}
const g: Grant = {
agentId, system, scopes, reason, grantedAt: s.now,
expiresAt: ttlMinutes === undefined ? undefined : s.now + ttlMinutes,
};
s.grants.push(g);
const until = g.expiresAt === undefined ? "" : ` until ${hhmm(g.expiresAt)}`;
log(s, "grant", agentId, `${system} [${scopes}]${until}: ${reason}`);
return g;
}
function revoke(s: Store, g: Grant, why: string): void {
g.revokedAt = s.now;
g.revokeReason = why;
log(s, "revoke", g.agentId, `${g.system}: ${why}`);
}
A grant gets rejected for four reasons. The agent isn't registered. The agent has ended. There's no reason. Or the scopes are above the role.
The important part is the ceiling check.
The caller can ask for less than the role allows. It can never ask for more.
A new grant for the same system replaces the old one. The old one gets revoked, with a reason, in the log.
ttlMinutes makes a grant time-boxed. That's the "when it needs it" part.
4. Step 3: Check on Every Action
// Step 3: check on every action
function checkAccess(s: Store, agentId: string, system: System, action: string): Decision {
const d = decide(s, agentId, system, action);
log(s, "check", agentId, `${system}.${action} ${d.allow ? "ALLOW" : "DENY"}: ${d.reason}`);
return d;
}
function decide(s: Store, agentId: string, system: System, action: string): Decision {
const agent = s.agents.get(agentId);
if (!agent) return { allow: false, reason: "not in the system of record" };
if (agent.status !== "active") return { allow: false, reason: `agent ${agent.status}` };
const mine = s.grants.filter((g) => g.agentId === agentId && g.system === system);
const live = mine.find((g) => isLive(g, s.now));
if (!live) {
const last = mine.at(-1);
if (!last) return { allow: false, reason: `no ${system} grant` };
if (last.revokedAt !== undefined) {
return { allow: false, reason: `revoked ${hhmm(last.revokedAt)}, ${last.revokeReason}` };
}
return { allow: false, reason: `expired ${hhmm(last.expiresAt!)}` };
}
if (!live.scopes.includes(action)) {
return { allow: false, reason: `scope ${action} not in [${live.scopes}]` };
}
return { allow: true, reason: live.reason };
}
The order matters.
First, is this agent in the record? Then, is it still active? Then, is there a live grant? Then, does the grant cover this action?
The deny reasons are specific on purpose.
"No finance grant" is a different problem from "revoked at 09:45, role changed."
"Expired at 11:00" is different again. A person debugging this later needs to know which one.
Every check goes in the audit log, allowed or not.
5. Step 4: Lifecycle Events Move Access
// Step 4: lifecycle events move access
function changeRole(s: Store, agentId: string, role: string): void {
const agent = s.agents.get(agentId);
if (!agent || agent.status !== "active") throw new Error(`${agentId} is not active`);
if (!ROLE_CEILING[role]) throw new Error(`unknown role ${role}`);
log(s, "role", agentId, `${agent.role} -> ${role}`);
agent.role = role;
for (const g of s.grants) {
if (g.agentId !== agentId || !isLive(g, s.now)) continue;
const ceiling = ROLE_CEILING[role][g.system] ?? [];
const keep = g.scopes.filter((x) => ceiling.includes(x));
if (keep.length === g.scopes.length) continue;
revoke(s, g, `role changed to ${role}`);
if (keep.length) grant(s, agentId, g.system, keep, `${g.reason} (trimmed for ${role})`, remaining(g, s.now));
}
}
function remaining(g: Grant, now: number): number | undefined {
return g.expiresAt === undefined ? undefined : g.expiresAt - now;
}
function endAgent(s: Store, agentId: string, why: string): void {
const agent = s.agents.get(agentId);
if (!agent || agent.status === "ended") return;
agent.status = "ended";
log(s, "end", agentId, why);
for (const g of s.grants) {
if (g.agentId === agentId && isLive(g, s.now)) revoke(s, g, `agent ended: ${why}`);
}
}
This is the heart of it.
changeRole walks every live grant and compares it to the new ceiling.
If a grant has scopes the new role can't hold, it gets revoked. Whatever is still allowed gets re-granted, trimmed, with the same expiry.
endAgent does two things in one call. It marks the agent ended. Then it revokes every live grant.
That second part is the one people skip.
Ending the agent and ending its access should be the same event.
6. Step 5: The Ticket Version
To see why, here's the version most of us start with.
// Step 5: the ticket version, and who has access right now
// The downstream app only sees grants. It never asks the system of record.
function appSees(s: Store, agentId: string, system: System, action: string): boolean {
return s.grants.some(
(g) => g.agentId === agentId && g.system === system && isLive(g, s.now) && g.scopes.includes(action),
);
}
// Ticket world: the agent is marked ended, but nobody files the access ticket.
function endInRecordOnly(s: Store, agentId: string, why: string): void {
const agent = s.agents.get(agentId);
if (agent) agent.status = "ended";
log(s, "end", agentId, `${why} (grants untouched)`);
}
function whoHasAccess(s: Store): string[] {
return s.grants
.filter((g) => isLive(g, s.now))
.map((g) => {
const a = s.agents.get(g.agentId)!;
const until = g.expiresAt === undefined ? "" : ` until ${hhmm(g.expiresAt)}`;
return `${g.agentId} (${a.status}, owner ${a.owner}) ${g.system} [${g.scopes}]${until}: ${g.reason}`;
});
}
appSees is what a downstream app knows. The email app doesn't call the system of record on every request. It sees a grant, or a token, and that's it.
endInRecordOnly is the ticket world. The agent is marked ended in one place. The access lives somewhere else.
whoHasAccess answers the release's question directly. Who, what system, which scopes, until when, and why.
7. Step 6: Run It
// Step 6: run it
function show(s: Store, agentId: string, system: System, action: string): void {
const d = checkAccess(s, agentId, system, action);
const verdict = d.allow ? "ALLOW" : "DENY ";
console.log(`${hhmm(s.now)} ${verdict} ${agentId.padEnd(12)} ${`${system}.${action}`.padEnd(14)} ${d.reason}`);
}
function at(s: Store, t: string): void {
const [h, m] = t.split(":").map(Number);
s.now = h * 60 + m;
}
const s = newStore();
console.log("== register and grant ==");
register(s, "pipeline-bot", "maya", "sales-assistant");
grant(s, "pipeline-bot", "email", ["read", "send"], "draft Q4 pipeline follow-ups");
grant(s, "pipeline-bot", "crm", ["read", "update"], "log calls from follow-ups", 120);
grant(s, "pipeline-bot", "finance", ["read"], "check deal discounts");
grant(s, "pipeline-bot", "crm", ["read", "delete"], "clean up duplicates");
for (const e of s.audit.filter((e) => e.kind === "grant" || e.kind === "reject")) {
console.log(`${e.at} ${e.kind.padEnd(6)} ${e.detail}`);
}
console.log("\n== check on every action ==");
at(s, "09:05");
show(s, "pipeline-bot", "email", "send");
show(s, "pipeline-bot", "crm", "update");
show(s, "pipeline-bot", "crm", "delete");
show(s, "pipeline-bot", "finance", "read");
show(s, "ghost-bot", "email", "read");
console.log("\n== role change ==");
at(s, "09:10");
register(s, "close-helper", "dev", "revops-analyst");
grant(s, "close-helper", "crm", ["read"], "match invoices to deals");
grant(s, "close-helper", "finance", ["read"], "month-end invoice exceptions", 240);
at(s, "09:40");
show(s, "close-helper", "finance", "read");
at(s, "09:45");
changeRole(s, "close-helper", "support-triage");
show(s, "close-helper", "finance", "read");
show(s, "close-helper", "crm", "read");
console.log("\n== time-boxed grant ==");
at(s, "11:05");
show(s, "pipeline-bot", "crm", "update");
show(s, "pipeline-bot", "email", "send");
console.log("\n== end the agent ==");
at(s, "11:30");
endAgent(s, "pipeline-bot", "Q4 follow-up project closed");
show(s, "pipeline-bot", "email", "send");
show(s, "pipeline-bot", "email", "read");
grant(s, "pipeline-bot", "email", ["read"], "one more thing");
const last = s.audit.at(-1)!;
console.log(`${last.at} ${last.kind.padEnd(6)} ${last.detail}`);
console.log("\n== same ending, ticket world ==");
const t = newStore(11 * 60 + 30);
register(t, "pipeline-bot", "maya", "sales-assistant");
grant(t, "pipeline-bot", "email", ["read", "send"], "draft Q4 pipeline follow-ups");
endInRecordOnly(t, "pipeline-bot", "Q4 follow-up project closed");
console.log(`ticket world: record says ended, email app allows send? ${appSees(t, "pipeline-bot", "email", "send")}`);
console.log(`lifecycle world: record says ended, email app allows send? ${appSees(s, "pipeline-bot", "email", "send")}`);
console.log("\n== who has access at 11:30 ==");
for (const line of whoHasAccess(s)) console.log(line);
console.log("\n== audit ==");
const counts: Record<string, number> = {};
for (const e of s.audit) counts[e.kind] = (counts[e.kind] ?? 0) + 1;
console.log(Object.entries(counts).map(([k, v]) => `${k} ${v}`).join(", "));
for (const e of s.audit.filter((e) => e.agentId === "pipeline-bot" && (e.kind === "end" || e.kind === "revoke"))) {
console.log(`${e.at} ${e.kind.padEnd(6)} ${e.detail}`);
}
Install and run:
npm install
npx tsx access-grant.ts
Output:
== register and grant ==
09:00 grant email [read,send]: draft Q4 pipeline follow-ups
09:00 grant crm [read,update] until 11:00: log calls from follow-ups
09:00 reject finance [read]: finance.read is above role sales-assistant
09:00 reject crm [read,delete]: crm.delete is above role sales-assistant
== check on every action ==
09:05 ALLOW pipeline-bot email.send draft Q4 pipeline follow-ups
09:05 ALLOW pipeline-bot crm.update log calls from follow-ups
09:05 DENY pipeline-bot crm.delete scope delete not in [read,update]
09:05 DENY pipeline-bot finance.read no finance grant
09:05 DENY ghost-bot email.read not in the system of record
== role change ==
09:40 ALLOW close-helper finance.read month-end invoice exceptions
09:45 DENY close-helper finance.read revoked 09:45, role changed to support-triage
09:45 ALLOW close-helper crm.read match invoices to deals
== time-boxed grant ==
11:05 DENY pipeline-bot crm.update expired 11:00
11:05 ALLOW pipeline-bot email.send draft Q4 pipeline follow-ups
== end the agent ==
11:30 DENY pipeline-bot email.send agent ended
11:30 DENY pipeline-bot email.read agent ended
11:30 reject email [read]: agent has ended
== same ending, ticket world ==
ticket world: record says ended, email app allows send? true
lifecycle world: record says ended, email app allows send? false
== who has access at 11:30 ==
close-helper (active, owner dev) crm [read]: match invoices to deals
== audit ==
register 2, grant 4, reject 3, check 12, role 1, revoke 2, end 1
11:30 end Q4 follow-up project closed
11:30 revoke email: agent ended: Q4 follow-up project closed
Here's what happened.
pipeline-bot asked for finance and for CRM delete. Both were above its role, so both were rejected at grant time.
At 09:05 it could send email and update the CRM. It couldn't delete records or read finance. It never got either one.
ghost-bot was never registered. Denied.
close-helper read finance at 09:40. At 09:45 it moved to a support role. Finance was revoked on the spot. CRM read stayed, because the new role still allows it.
At 11:05, the CRM grant from 09:00 had expired. Email still worked, because that grant had no expiry.
At 11:30, pipeline-bot ended. Every check after that says "agent ended." A new grant request was rejected too.
Then the comparison.
In the ticket world, the record says ended. The email app still allows send.
That's the retired agent from the release. A retired agent with live credentials is just a ghost with an API key.
In the lifecycle world, the same app says no. The revoke reached the grant.
The audit log kept all of it. Two registrations, four grants, three rejections, twelve checks, one role change, two revokes, one ending.
8. Where It Breaks Down
This is a demo. Real access is much messier.
The revoke has to reach every app. In this demo, appSees reads the same array as the record. That's cheating. Real apps hold their own tokens, sessions, and API keys. Revoking means calling each one.
"Immediately" is hard. A cached token or a long session keeps working until it expires. Short-lived credentials help. So does making apps check back.
Scopes are strings here. Real permission models are nested, per-object, and different in every system. "crm.update" hides a lot.
On-behalf-of agents need an intersection. The release says some agents "act on behalf of an employee and should only reach part of what that person can."
This model has an owner field. It never checks the owner's own access.
A real version should cap the agent at both the owner's access and the role.
There's no approval step. Grants here are instant. C1.ai describes "human approval" as part of its platform. A real grant for finance should probably wait for a person.
The audit log is in memory. Anyone with the store can rewrite it. A real one is append-only and stored somewhere the agent can't touch.
The role ceilings are hand-written. Someone has to decide them. That's a policy problem, not a code problem.
None of that changes the core idea. It just makes the plumbing bigger.
9. The Bigger Idea
This part is prediction, not history.
I think agent identity is going to look a lot like employee identity.
Not because agents are people. Because companies already run one process for "start, move, leave."
The shape looks like this:
Lifecycle event (register, role change, end)
↓
System of record (who it is, who owns it, what role)
↓
Grants (system, scopes, reason, expiry)
↓
Check on every action
↓
Audit log (who had access, and why)
The model provides reasoning.
The system of record provides identity.
The role provides the ceiling.
Grants provide the reason.
Lifecycle events provide the off switch.
The audit log provides the answer when someone asks.
Workday buying C1.ai, if it closes, is a bet that this lives next to HR.
That's a big bet. I think it's the right instinct.
Agents are going to have first days. They should have last days too.
Access should start with a reason and end with the work.
Try Roster
I'm building Roster around this idea: AI employees with real responsibilities, tools, memory, schedules and computer access. Real access means it needs a clear start, a clear owner, and a clear end.
If the same follow-ups, handoffs, and waiting loops keep eating your week, give them to an AI employee.


Top comments (0)