Skip to content
bitscrafter

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.

Architecture: its sections around it, in reading order

  1. Principles — the LLM proposes, the policy engine decides
  2. Context — two kinds of people, two outside services
  3. Structure — hexagonal and clean architecture
  4. Components — ten subsystems in a layer order
  5. Flow — two paths, one decision point
  6. Concurrency — one mechanism for each thing at once
  7. Deployment — one Worker on Cloudflare
  8. Network — an untrusted browser, one request guard
  9. 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.

principlerecordwhat it means here
Hexagonal and clean architectureCM-1each subsystem a model, its use cases and its infrastructure, dependencies pointing inward
PostgreSQL through Hyperdrive, the system of recordCM-2transactions and constraints hold the data’s integrity
Never delete identities or historyCM-7rows end; none is removed
The database enforces structure; code enforces rulesCM-13keys and checks in SQL, decisions in use cases
UUIDv7 ids, stored natively, shown with a prefixCM-14ordered ids, never guessed
Foreign keys point down the layer orderCM-15a subsystem refers only to those beneath it; attribution by soft reference
The session token carries identity onlyCM-16the principal reloaded on every request
The case agent is a hardened AIChatAgentCM-18every frame guarded, the request rebuilt
gpt-oss-120b answers, and the screen writes namesCM-19the model writes ids; the screen resolves them
RBAC grants, policies constrainAC-1roles give permissions; policies only take away
PDP and PIP in authorization; PEPs are use casesAU-5every 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).

The context: a representative and a supervisor at the Entitlement Assistant, the customer's contact through the representative, Workers AI and PostgreSQL beside it

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).

The rings: the model at the centre, the use cases and ports around it, adapters and infrastructure outside them, the application around all

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 seed

4. 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).

The ten subsystems, from customer and internal identity at the bottom to support at the top, attribution beside them

Figure 4 — The ten subsystems, in their order

Figure 4 draws the order; the table gives each subsystem’s responsibility, from the bottom up.

subsystemits responsibility
customer identitythe people and organizations a provider serves
internal identitythe provider’s employees, their password hashes, the system identities
accountcustomer and internal accounts, their holds, their versions
productplans, add-ons, features and their operations
accessroles, permissions and their assignments
entitlementsubscriptions, their terms and add-ons, and grants
policythe versioned policy set, its policies in CEL
authorizationthe decision point and its information point: no tables
supportcases, their assignments, change requests and approvals
attributionthe 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.

componentits responsibility
Worker’s entryserves the assets, routes /api and /agents, runs the sweep on its schedule
request guardHTTPS only, the Origin, the session, the principal reloaded
compositionbuilds the use cases on one connection and one transaction a request
case agenta Durable Object a case: the conversation, the turns, the tools
WebSocket message guardchecks every frame, rebuilds the request from the stored conversation
agent’s toolsnine: eight reach a use case, one only drafts text
approval process workflowone an approval: waits for its decision until the deadline
outbox relayafter commit, starts or wakes the workflow
UIthe 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.

adapterwhat it stands for
clockthe time, so tests can hold it
identifierUUIDv7 ids
session tokenthe signed session token, its issue and its check
postgresevery repository, the transactional outbox, the audit recorder
message rate limiterthe sliding minute, a Durable Object
daily allowancethe counts of a UTC day, a Durable Object
security event logthe 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).

A request's path: the browser, the request guard, the API or the case's agent, Workers AI, the decision point, PostgreSQL, the outbox relay and the approval workflow

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).

The deployment: the Worker with its three Durable Objects, its Workflow and its bindings, beside Workers AI, Hyperdrive and PostgreSQL

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.

bindingproductused by
AIWorkers AIthe case agent
HYPERDRIVEHyperdrive, to PostgreSQLevery repository
CASE_AGENTthe Durable Object CaseAgentObjectthe /agents path
MESSAGE_RATE_LIMITERthe Durable Object of the sliding minutethe message guard, the account search, the sign-in
DAILY_ALLOWANCEthe Durable Object of the day’s countsthe message guard
APPROVAL_PROCESSthe Workflow ApprovalProcessWorkflowthe 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).

The network: the untrusted internet, the Worker behind its request guard, the API and the WebSocket, Workers AI and Hyperdrive beyond

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