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

  1. The Problem
  2. Step 1: An Agent System of Record
  3. Step 2: Grants That Live Inside the Role
  4. Step 3: Check on Every Action
  5. Step 4: Lifecycle Events Move Access
  6. Step 5: The Ticket Version
  7. Step 6: Run It
  8. Where It Breaks Down
  9. 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 Source:https://dev.to/bobbyhalljr/workday-is-buying-identity-for-ai-agents-lets-build-a-tiny-access-grant-in-typescript-25m4

Related posts