Entitlement Assistant · Architecture
- Lab
- Cloudflare
- Authorization
- AI Agents
This part describes the system by the viewpoints Nick Rozanski and Eoin Woods name: its context, its functions — the components and the overall flow — its development structure, its concurrency, its deployment and its operation, each preceded by the principles they follow. The data and the use cases have parts of their own, as do authorization and security.


- Principles — the LLM proposes, the policy engine decides
- Context — two kinds of people, two outside services
- Structure — hexagonal and clean architecture
- Components — ten subsystems in a layer order
- Flow — two paths, one decision point
- Concurrency — one mechanism for each thing at once
- Deployment — one Worker on Cloudflare
- Network — an untrusted browser, one request guard
- Operations — the sweep, every 15 minutes
1. Principles
One principle governs the rest: the LLM proposes; the policy engine decides. The project records it as CM-5, with 97 more decisions beside it, each with its alternatives and its reason. Eleven of them shape what this part describes, and the table below gives each with its record.
1.1 The principles followed
1.1 The principles followed
The records are short; their numbers are the project’s, so each can be read in its own words.
| principle | record | what it means here |
| Hexagonal and clean architecture | CM-1 | each subsystem a model, its use cases and its infrastructure, dependencies pointing inward |
| PostgreSQL through Hyperdrive, the system of record | CM-2 | transactions and constraints hold the data’s integrity |
| Never delete identities or history | CM-7 | rows end; none is removed |
| The database enforces structure; code enforces rules | CM-13 | keys and checks in SQL, decisions in use cases |
| UUIDv7 ids, stored natively, shown with a prefix | CM-14 | ordered ids, never guessed |
| Foreign keys point down the layer order | CM-15 | a subsystem refers only to those beneath it; attribution by soft reference |
| The session token carries identity only | CM-16 | the principal reloaded on every request |
| The case agent is a hardened AIChatAgent | CM-18 | every frame guarded, the request rebuilt |
| gpt-oss-120b answers, and the screen writes names | CM-19 | the model writes ids; the screen resolves them |
| RBAC grants, policies constrain | AC-1 | roles give permissions; policies only take away |
| PDP and PIP in authorization; PEPs are use cases | AU-5 | every use case asks before it acts |
2. Context
The system meets two kinds of people and two outside services. A representative works cases in a chat and confirms changes; a supervisor decides the approvals a policy asks for. The customer’s contact talks to the representative and never to the system. Two system identities act where no person clicks: one records grants’ expiries, the other runs the approval process (Figure 2).


Figure 2 — The Entitlement Assistant and what it meets
Figure 2 shows the outside services: Workers AI, which runs the model, and PostgreSQL, reached through Hyperdrive. The model sees the conversation and proposes; PostgreSQL holds every record. Nothing else outside the Worker takes part in a case: no queue, no cache, no store of keys and values, no other service a request waits for.
The context leaves things out on purpose. Customers never reach the system: there is no page for them, and what they ask arrives through a representative who has authenticated them. Nor does the assistant reach other systems: it holds nine tools, all its own, and the project lists exposing them to other agents — as a server of the Model Context Protocol — among what production would add, not what the lab does: such a server would need its own clients registered, its scopes mapped to permissions, and each client attributed in the audit chain.
3. Structure
The code follows two architectural styles, hexagonal (ports and adapters) and clean: each of ten subsystems is a model, its use cases and its infrastructure, and dependencies point inward — the model knows no input or output, the use cases know the model and the ports they need, and the infrastructure implements those ports. The application composes everything into one Worker (Figure 3).


Figure 3 — The rings and their directories
Figure 3 maps each ring to its directories. dependency-cruiser holds the rings in every build: a model has no input or output, dependencies point inward, subsystems meet through use cases alone, and no cycle is allowed. A dependency that breaks a ring fails the check before any test runs.
Inside the use-case ring every use case follows one template, so none can skip a step. A read of a customer’s account is decided as one action and writes exactly one audit record; a case’s action is authorized, executed and audited in that order. The template is the policy enforcement point, and there is one a kind of use case rather than one a developer remembers to write.
3.1 The directory tree
3.1 The directory tree
Every folder of typescript/system/ is a ring or the ground beneath them: kernel/ and external/
hold shared types and the wrappers of outside libraries, and no domain.
typescript/system/
kernel/ shared types: results, ids, names quoted, usage caps
external/ outside libraries wrapped: agents, AI, CEL, JOSE, PostgreSQL, UUIDs, YAML
port/ the ports use cases name
adapter/ clock, daily allowance, identifier, message rate limiter,
postgres, security event log, session token
subsystem/
customer-identity/ internal-identity/ account/ product/ access/
entitlement/ policy/ authorization/ support/ attribution/
src/model/ the rules alone
src/usecase/ the use cases: authorize, execute, audit
src/infra/ their adapters and migrations
application/entitlement-assistant/
src/worker.ts the Worker's entry
src/agent/ the case agent's tools, instructions and guard
src/http/ the request guard, responses, the session cookie
src/ui/ the case chat, the case panel, the approvals
src/database/ migrations and seed4. Components
The ten subsystems stand in a layer order, each depending on those beneath it and reaching them through their use cases alone; foreign keys point down the same order. Attribution stands apart: every subsystem writes to it, and it refers back by soft reference alone, so it depends on none (Figure 4).


Figure 4 — The ten subsystems, in their order
Figure 4 draws the order; the table gives each subsystem’s responsibility, from the bottom up.
| subsystem | its responsibility |
| customer identity | the people and organizations a provider serves |
| internal identity | the provider’s employees, their password hashes, the system identities |
| account | customer and internal accounts, their holds, their versions |
| product | plans, add-ons, features and their operations |
| access | roles, permissions and their assignments |
| entitlement | subscriptions, their terms and add-ons, and grants |
| policy | the versioned policy set, its policies in CEL |
| authorization | the decision point and its information point: no tables |
| support | cases, their assignments, change requests and approvals |
| attribution | the audit chain, its participants and reasons |
Two services cross every layer: deciding and recording. They are ports in the shared port/
directory, wired when the Worker starts, so a use case low in the order — a grant’s expiry, a hold —
can ask the decision point and write the audit chain without depending on the subsystems above it.
The project decided this when those use cases had begun to create cycles, and the shared ports removed
them.
4.1 The application's components
4.1 The application’s components
The application composes the subsystems into a Worker. Each of its components does one thing, its name in the table below, and none of them decides whether an action is allowed: that stays with the decision point.
| component | its responsibility |
| Worker’s entry | serves the assets, routes /api and /agents, runs the sweep on its schedule |
| request guard | HTTPS only, the Origin, the session, the principal reloaded |
| composition | builds the use cases on one connection and one transaction a request |
| case agent | a Durable Object a case: the conversation, the turns, the tools |
| WebSocket message guard | checks every frame, rebuilds the request from the stored conversation |
| agent’s tools | nine: eight reach a use case, one only drafts text |
| approval process workflow | one an approval: waits for its decision until the deadline |
| outbox relay | after commit, starts or wakes the workflow |
| UI | the case chat, the case panel, the approvals; text rendered as text |
4.2 The adapters
4.2 The adapters
Every side effect is a port, and every port has its adapter, so a use case can be tested with a stand-in and run on Cloudflare with the real thing.
| adapter | what it stands for |
| clock | the time, so tests can hold it |
| identifier | UUIDv7 ids |
| session token | the signed session token, its issue and its check |
| postgres | every repository, the transactional outbox, the audit recorder |
| message rate limiter | the sliding minute, a Durable Object |
| daily allowance | the counts of a UTC day, a Durable Object |
| security event log | the security events: refusals, failed sign-ins |
5. Flow
A request takes one of two paths. A page’s clicks reach /api, where an API endpoint runs one use case; the
chat reaches /agents, where the case’s agent runs a turn of the model and its tools. Both paths meet
at the same use cases and the same decision point, and both write to PostgreSQL in one transaction (Figure 5).


Figure 5 — A request's path, end to end
Figure 5 follows the path. A use case authorizes, executes and audits; whatever it changes and its audit record commit together, and a message to the outbox commits with them. Only after the commit does the relay start or wake the approval workflow, so a workflow never acts on a change that rolled back.
The chat’s path is hardened at its door. The case agent extends the Agents SDK’s AIChatAgent, which
as written stores whatever history a browser sends and accepts messages that clear the chat or submit
tool results: a page could forge earlier replies. A guard in front of the SDK handler drops all of
that. Only the newest message’s text passes, the conversation is the server’s own, and the SDK keeps
what it does well — the chat protocol, resumable streams and live updates across a representative’s
tabs.
6. Concurrency
Several things run at once, and each is kept right by one mechanism. A case’s agent is one Durable Object, so its turns run one at a time. An approval’s workflow is one instance whose id is the approval’s, so starting it twice starts it once. Records join the audit chain one at a time, the chain’s head locked for update while each does.
A change applies only to the account it was prepared for. Applying it locks the account’s version and checks it and the change’s diff; if the account moved since the preview, the request ends canceled as stale instead of applying to something no one saw. The outbox delivers at least once: the relay after the commit, the sweep again for any message older than a minute.
Repetition is harmless by design. Confirming a change twice applies it once: the second confirmation answers with the first one’s response, and nothing is decided or written again. A message the relay delivers and the sweep delivers again starts no second workflow, since the instance’s id is the approval’s, and a decision delivered twice wakes a workflow that has already moved on.
7. Deployment
Everything runs in one Worker on Cloudflare. It serves the UI’s assets and runs before them for /api
and /agents; a Cron every 15 minutes runs the sweep. Three Durable Objects hold the case agents, the
rate limiter and the daily allowance, each in SQLite; one Workflow runs the approvals (Figure 6).


Figure 6 — On Cloudflare
The model is Workers AI’s @cf/openai/gpt-oss-120b, called without streaming and its answer replayed
as a stream, five steps a turn at most, the last without a tool. PostgreSQL runs on Neon behind
Hyperdrive, its cache off. D1 is not used: the project keeps its records in PostgreSQL for its
transactions and constraints. Queues are not used either: the database is the outbox, and Queues are
named for scale.
A case lives in a Durable Object because a case is a conversation with one owner at a time: its own SQLite holds its messages, its single thread orders its turns, and its name — the case’s id — finds it from any request. The records a case changes stay in PostgreSQL, where transactions span accounts, grants and the audit chain; the agent holds the conversation and nothing a customer has.
7.1 The bindings
7.1 The bindings
Each binding names a product the Worker reaches; the code reaches nothing else.
| binding | product | used by |
AI | Workers AI | the case agent |
HYPERDRIVE | Hyperdrive, to PostgreSQL | every repository |
CASE_AGENT | the Durable Object CaseAgentObject | the /agents path |
MESSAGE_RATE_LIMITER | the Durable Object of the sliding minute | the message guard, the account search, the sign-in |
DAILY_ALLOWANCE | the Durable Object of the day’s counts | the message guard |
APPROVAL_PROCESS | the Workflow ApprovalProcessWorkflow | the outbox relay, the sweep |
8. Network
The browser is untrusted; the boundary is the request guard. Plain HTTP gets a permanent redirect to HTTPS. Every request that changes something, and every WebSocket upgrade, must carry exactly the application’s Origin, and there is no CORS. The session is a cookie; the principal it names is reloaded from the database on every request, so a suspended employee stops at the next one (Figure 7).


Figure 7 — The network and its trust boundaries
Figure 7 draws the boundaries. The WebSocket upgrade passes the same guard and more: a well-formed case, the caller its current assignee. The upgrade reaches the case’s agent with its handshake alone and the session’s id, and every frame after it is guarded again. The UI’s Content-Security-Policy allows scripts from the application alone, and the UI renders every text as text.
Workers AI sees the conversation: the last 20 messages, the case’s events as facts, and the tools’ results. It sees no names the screen will show — the model writes an account as its bracketed id, and the screen writes the name, from the same reply’s results alone. It reaches nothing: every tool call it proposes comes back to the agent, which runs it as the representative, through the decision point.
9. Operations
The system runs itself between people’s clicks. Every 15 minutes the sweep records grants’ expiries, wakes approvals past their deadline or approved and not applied, and delivers again the outbox’s undelivered messages — at most 100 messages, 100 approvals past their deadline and 100 approved and not applied a run, each stage apart, so one failing stops none of the others. A message that has failed 50 times is delivered no more.
One setting caps the model’s daily use: a budget of Workers AI’s neurons, divided by an estimate a
call, gives the day’s model calls, and each person may use 40 % of them in messages. Setting the
budget to zero turns the assistant off, and everything else keeps working. Before a demo, a reset
command, run from the owner’s machine and only with --yes, drops the database’s public schema, runs
the migrations, reseeds the fixtures dated to that day, and registers the bundled policy set: cases,
grants, approvals and the audit chain are all replaced.
What an operator sees is the Worker’s log and the audit chain. Security events — a refused request, a failed sign-in — go to the log through their own port; every change and nearly every decision on customer data is in the chain, with its actor, its outcome, its reasons and the policy set’s version, so a question about who did what, and why it was allowed, is answered from records rather than from memory.
References
- Software Systems Architecture: viewpoints and perspectives — Nick Rozanski and Eoin Woods.
- The project’s records:
tech-docs/adr/README.md, its 98 decisions. AI_AS_ARCHITECT.md— the project’s design rules.