A permission-aware AI platform over a company's own business data. Employees ask in the tool they already use; agents answer, retrieve, execute and collaborate; work gets done through connected systems, a browser and a computer. Written to be built from.
A Company Brain answers questions and performs work over a company's own operational data, while enforcing that each person sees only what they are entitled to see — decided per request and per field, not per document.
It installs on the client's own server. One deployment serves one company. Departments are filters within it, not separate systems, so adding the ninth department costs configuration rather than a rebuild.
That is the architectural thesis. An agent roster, a visual builder, agents delegating to one another, a browser driving a client's accounting system — each is a permission problem before it is a feature problem. One decision point is what stops those features fighting each other.
Every layer, top to bottom. Read this once before anything else; the rest of the document expands each band.
Decided: Python. It is the better fit here, and the reason is not preference — it is that four of the components this system depends on most are Python-native, and a TypeScript build would have needed a separate Python sidecar container to run them anyway.
| Layer | Choice | Licence | Health |
|---|---|---|---|
| Agent loop, both lanes | LangGraph | MIT | 41.0k stars, pushed 3 Sep |
| Typed agent contracts | Pydantic AI | MIT | 19.7k stars, pushed 4 Sep |
| API and console backend | FastAPI + Uvicorn | MIT / BSD-3 | 102k stars, pushed 1 Sep |
| Console frontend | React + Vite + React Flow | MIT | — |
| Job queue | Procrastinate (Postgres-native) | MIT | 1.4k stars, pushed 31 Aug |
| Durable execution | Hatchet, or Temporal at scale | MIT | 7.9k / 1.2k, both pushed 4 Sep |
| Browser automation | Playwright for Python | Apache-2.0 | 15.0k stars, pushed 3 Sep |
| Browser agent reference | browser-use | MIT | 112k stars, pushed 4 Sep |
| Document parsing | Docling | MIT | 66.0k stars, pushed 4 Sep |
| PII detection | Presidio | MIT | 10.7k stars, pushed 31 Aug |
| Entity resolution | Splink (offline calibration) | MIT | 2.4k stars, pushed 3 Sep |
| ORM and migrations | SQLAlchemy + Alembic | MIT | 4.4k stars, pushed 3 Sep |
| Packaging and tooling | uv | Apache-2.0 | 89.4k stars, pushed 4 Sep |
| Testing | pytest + promptfoo for evals | MIT | — |
Changes. No sidecar — Docling, Presidio, Splink and the eval harness are imports in the same process family. Pydantic becomes the entity-tagged result envelope, which is a genuinely better fit than a TypeScript equivalent because the redaction walker operates on validated models rather than plain objects. And LangGraph is available natively, with its checkpointer and interrupt primitives.
Does not change. The gate, the redaction walker, the tool registry, the leash, the three lanes, the data tiers, entity resolution, the org model, and every diagram in this document. Those are language-independent by design, which is why this decision could safely be deferred until now.
LangGraph's library is MIT, but langgraph-api — the server providing persistence, task queues and crash recovery — is Elastic-2.0 and needs a commercial key in production. We do not use it. The open-source Postgres checkpointer saves state; what it lacks is something to re-drive a thread after a crash. That driver is ours to build, roughly a week's work, and it sits behind the same adapter seam described in §20 so Hatchet or Temporal can replace it without touching the graphs.
One import note: Uvicorn's repository moved from the encode organisation to Kludex/uvicorn. Same package, but pin it and know where upstream lives.
No term is defined using another undefined term. Read this before any later section.
| Term | Definition | Representation |
|---|---|---|
| Principal | An authenticated identity — person, service or partner. | uuid from Keycloak |
| Scope | A predicate over rows. This is what a department actually is. | jsonb {"department":"web"} |
| Capability | An atomic permission. Authorisation is set membership over these. | read:client.contract_value |
| Entitlement set | Computed per request: every (capability, scope) pair held now. Its hash keys the cache. | E(principal) → ent_hash |
| Term | Definition | Key property |
|---|---|---|
| Knowledge | Human-authored, approved content with an owner, verified date and review date. | Distinguished from Memory by authorship. |
| Asset | An uploaded file — PDF, document, image — parsed and indexed into Knowledge. | We are the source. Contrast with a Connector. |
| Skill | A procedure with no side effects. A SKILL.md folder. | Importable from GitHub, URL or upload. Not executable until reviewed. |
| Tool | An executable function with typed input and output. | The only grantable thing. |
| Connector | A deployment unit: transport, credential, lifecycle. | Never granted — granting it would grant everything behind it. |
| Surface | A browser or desktop session the agent drives. Output is pixels and DOM. | Untyped, so a weaker, separately named guarantee. |
| Memory | System-authored observation. Three kinds. | Never authoritative over the database. |
| Agent | A named configuration: persona, knowledge, skills, tools, model tier, leash. | A lens, never an identity. |
| Template | A signed, versioned, immutable agent definition. | Every agent is an instance of one. |
| Automation | A saved task with a trigger: schedule, event or webhook. | Resolves its owner's live entitlements at each run. |
| Leash | The autonomy rung on (agent, target, scope). | A database row, never code structure. |
E(principal), cached 60 seconds, invalidated on any grant write. Compute ent_hash.ent_hash + agent config hash + policy epoch.agent grants ∩ E. A tool the caller cannot use is absent, so there is nothing to jailbreak toward.Steps 1 to 9 all happen before any model runs. The result contract that makes step 12 possible:
{"@entity":"client","@id":"c_123","hours_remaining":12,"contract_value":48000,
"tickets":[{"@entity":"ticket","@id":"t_9","subject":"…","internal_note":"…"}]}
Company → Departments → Users, exactly as you described. Agents exist at three visibility levels.
| Level | Created by | Visible to | Example |
|---|---|---|---|
| Global | Super Admin | Everyone in the company | Internal Helpdesk, Site Health Sentinel |
| Department | Department Admin | Members of that department | Ticket Triage for Maintenance |
| Personal | Any member | Only its creator, unless published | A designer's own brief-writing agent |
This is the single most important rule in the permission model, and it is worth knowing that two shipping products do the opposite. Notion's Custom Agents documentation states an agent "responds using the agent's own access, not the permissions of the person who triggered it". Dust states an agent "inherits the access right to the data that has been used… independently of who uses the agent".
Under our model a Super Admin can publish a company-global agent that reaches finance tools, and a junior invoking it still sees only what that junior is entitled to. That makes cross-department agents safe to publish globally, which the creator-inherits model forfeits. It is a competitive advantage as much as hygiene.
Formally: E_run(caller, agent) = E(caller) ∩ agent_ceiling. The agent id is not an argument to the entitlement resolver, not in the cache key, and not visible to the redactor. The ceiling can only shrink an already-computed set.
Two separate tables answering two separate questions. Roles govern the platform — publish, install, appoint, read audit metadata — and are a compiled constant, not editable rows. Entitlements govern data as (capability, scope) pairs. No role implies a capability, including Super Admin.
| Role | Scoped | Exists to | Typical count |
|---|---|---|---|
| Super Admin | company | Own the platform: publish global agents, change the catalogue, confirm nominations, disable principals | 2–4 |
| Department Admin | required | Run one department: approve its publications, grant within its scope, adopt orphaned agents, lower leashes | 1 per dept |
| Member | — | Ask questions, build personal agents, own their delegations | everyone |
| Auditor | — | Read the metadata plane end to end, including Super Admin activity. Never the content. | 1–2 |
| Connector Admin | — | Install connectors, bind and rotate credential references | 1–2, deliberately not the Super Admins |
| Approver | required | Approve Assisted-rung actions, within their own entitlement | per dept |
| Concept | Modelled instead as | Why |
|---|---|---|
| Contractor / external | an attribute + mandatory expiry | Same platform verbs, different lifecycle. A role would duplicate the whole table. |
| Installing partner (you) | a principal kind with break-glass sessions | Zero standing entitlement, its own immutable audit chain. Structural, not permissional. |
| Knowledge owner / curator | a per-object stewardship relation | A global Curator means one person curating everything — wrong and unstaffable. |
| Builder | nothing — building is unrestricted | Gate publication, not building. That is the governance moment. |
| Service account | a standing delegation resolving the owner's live entitlements | A service principal with its own grants is union authority, which is the classic escalation. |
| Deputy | a time-boxed role grant, max 30 days, depth 1 | Covers annual leave without an unbounded appointment chain. |
Oversight and data access are different things, and separating them is what lets a Super Admin do their job without becoming a universal reader.
| Plane | Governed by | Example |
|---|---|---|
| Existence | role | The Payroll knowledge base exists, has 412 documents, is owned by HR, was synced at 14:02, and produced 3 refusals today. |
| Configuration | role | Agent instructions, tool grants, leash rungs, connector endpoints. Credential values are visible to nobody, ever. |
| Content | entitlement only | Document bodies and field values. A Super Admin sees none of it without a grant. |
A Super Admin can grant themselves a capability — refusing that would just push people to the database — but doing so writes a loud audit event and notifies the object's steward. The Auditor role exists so that action is visible to someone who cannot perform it.
The mover case is where permission systems actually leak. When someone changes department: entitlements re-resolve on the next request automatically; personal agents follow them; their delegations are re-evaluated and any that would now exceed their entitlements are suspended, not silently narrowed; cached answers keyed on their old ent_hash become unreachable by construction; and memories tagged with the old capability set stop being replayed.
Confirming your question directly: we do not sync connector data. With one deliberate, bounded exception, described below, because pure federation is arithmetically impossible.
| Tier | Contains | Stored? |
|---|---|---|
| Local | Identity, capabilities, audit, leash state, agent and template config, uploaded knowledge and assets, memory, the entity registry | yes — we are the source |
| Projected | Record ids, join keys, status enums, timestamps, display labels ≤120 chars, and the source's visibility predicate. Max 12 fields per entity type. | a pointer, not the payload |
| Federated | Ticket bodies, conversations, invoice lines, contracts, CRM notes, attachments, custom fields, email, phone, address, NRIC, bank details, salary | never |
The projection is roughly 40 MB at your scale, against tens of gigabytes for a mirror. Email, phone, NRIC, bank details and salary are on a permanent deny list with no exception.
| Source | Per minute | Per day | Consequence |
|---|---|---|---|
| Xero | 60 | 5,000 per tenant | Shared with every other integration the client runs. |
| Freshdesk | 100 / 400 / 700 | — | Per account. Ticket listing capped harder still. |
| Lark Base | 100 fixed | — | Their docs state it cannot be raised. 1.67 calls/second for the whole tenant, permanently. |
A realistic question touches 5–20 records. Federating everything means 15,000–60,000 calls a day against a Xero ceiling of 5,000. Separately, Freshdesk's search returns at most 300 records ever, so "which clients have an open P1 older than five days" has no live answer at any speed. Headers must be local; bodies never are.
Never store a resolved ACL. Store the source's visibility predicate and evaluate it against the live entitlement set. When someone changes department, the next query returns a different row set with zero writes and zero invalidation. Microsoft's own connector docs admit their incremental crawls do not update permissions at all; Glean's full crawls run on 28-day cycles. This is where the category fails and we do not.
The rule for a new field: if the fast lane must filter, sort or count on it and the source will tell us when it changes, project it. Otherwise fetch it live. No change signal means no projection — that clause is what stops the projection becoming a mirror.
You identified the hardest problem in the design. "ABC" in Xero, "ABC Pte Ltd" in the maintenance portal, "ABC Pte" in HubSpot. The same for people and projects, and one client may legitimately have several names.
This is not a nice-to-have: the entity registry is the join key that makes federation possible at all. Without it the system cannot know which Freshdesk company, which Xero contact and which Laravel row are one organisation, so it cannot fetch the right record live. It is why the registry sits in the LOCAL tier.
| Stage | Signal | Strength | Action |
|---|---|---|---|
| 1 · Hard identifier | UEN, tax id, web domain, verified phone | decisive | Auto-link. A shared UEN is essentially never coincidence. |
| 2 · Normalised name | case, punctuation and suffixes stripped: Pte Ltd, Sdn Bhd, Inc, LLC, Limited | moderate | Auto-link only with corroboration from a second field. |
| 3 · Fuzzy similarity | trigram similarity, edit distance | weak | Above the upper threshold and corroborated, link. Otherwise queue. |
| 4 · Human review | the ambiguous band | deferred | A person confirms, with the evidence shown as a weighted list. |
The scoring is Fellegi-Sunter: each field contributes a weight reflecting how reliably it agrees on true matches versus how often it agrees by coincidence. That gives two thresholds rather than one, and the band between them is the review queue — it falls out of the model rather than being bolted on. It also gives the reviewer their explanation for free: "same web domain, strong; similar name, weak; different phone, negative".
Three specifics that matter for Singapore. A free-mail domain like gmail.com must be blocklisted, not merely weighted low, because it agrees constantly by coincidence. Phonetic matchers such as Soundex are English-centric and documented as unreliable on multibyte text, so they are actively harmful on romanised Chinese, Malay and Tamil names — use edit distance and the Daitch-Mokotoff variant instead, weighted low. And trigram similarity is unstable on very short strings, so anything under four normalised characters goes straight to review.
Salesforce cannot undo a merge. HubSpot states plainly that records cannot be unmerged. Both destroy the pre-image, which is why a small industry exists to recover from it.
We can do better cheaply, because we never own the source records — the registry is a mapping layer over immutable source ids, so a merge moves pointers only. Every merge writes the complete prior state of the affected links and aliases into an append-only decision row first. That single column turns unmerge from impossible into an update. A canonical id, once issued, resolves forever through a forwarding pointer, so an old memory citation never dangles.
The resolver holds hashed join keys rather than full records — roughly 7 MB of non-reversible keys for 60,000 links, against 80 MB of live personal data if we synced contacts. Tooling: Splink (MIT, actively maintained) run offline as a weekly job to calibrate the weights, with the weights exported as a small table and the same arithmetic evaluated online in plain SQL using pg_trgm and fuzzystrmatch. That keeps a Python ML dependency out of the request path and keeps online scoring around a millisecond.
Two retrieval planes over one Postgres.
The row plane reaches business records through typed tools whose column projection is compiled from the caller's capabilities before the query runs, so a hidden field is never in the SQL and never crosses the socket. No model ever writes SQL — an LLM authoring queries against a business database defeats any column policy.
The document plane is hybrid search over knowledge and assets: Postgres full-text and pgvector fused by rank, with the scope filter inside the query so out-of-scope rows are absent rather than ranked lower. Assets flow through parsing (Docling, layout-aware so tables in PDF contracts survive), chunking, embedding, and indexing.
This sits directly on the scope primitive and fails quietly — the answer is just thin and nobody files a bug. Either enable hnsw.iterative_scan explicitly, or at these corpus sizes use a partial index per scope, which removes the failure mode instead of tuning around it.
Citations for a row cite the record and the field — "Acme, hours remaining, read 14:02" — because a link to a record does not say which cell the number came from. Every knowledge row carries verified-by, verified-at and review-by, surfaced as a badge, with a scheduled job opening re-verification tasks.
Everything uploaded — SOPs, price lists, package definitions, brand guidelines, contracts, meeting notes — lands in one knowledge layer. There is no second store per department. What differs is the scope predicate attached to it, and that is what decides who reaches it.
| Level | Set by | Reachable by | Typical |
|---|---|---|---|
| Company | Super Admin, or a Department Admin proposing and a Super Admin approving | Everyone, subject to field-level redaction | HR policy, brand guidelines, the standard price list, escalation rules |
| Department | Department Admin | That department's scope | The Web team's deployment SOP, Maintenance's triage rules, Finance's package costings |
| Personal | The uploader | Only them, until published | Working notes, a draft proposal, a client call transcript |
Without that rule every upload silently widens the company's exposure, and within a year nobody can say why the whole company can read a client contract.
It is right that the company-level brain holds all the knowledge — there is one store and one index. It is not right that a company-level agent has access to all of it. Access is never a property of the agent; it is always the intersection with the person asking, per §6.
So a global "Company Assistant" published to all 126 staff is safe precisely because it has no standing authority. Someone in Web asking it about a Finance SOP gets nothing, or gets the parts they are entitled to, with the rest shown as locked fields. The same agent asked the same question by the Finance Director returns the full answer. One agent, one knowledge layer, different answers, and no configuration needed to make that true.
Two consequences worth designing for. Pricing and packages are a field-level problem, not a document-level one — a price list usually carries cost and margin columns beside the sell price, and the whole company needs the sell price. Classify those columns rather than restricting the document, or you will end up with three near-identical price lists and two of them stale. And knowledge inherits the mover case: when someone changes department, the department knowledge they could read stops resolving on their next request, with no reindex and no cache purge, because the scope predicate is evaluated live.
Templates are how every agent exists — a hand-built one is an instance of the blank template. One code path, one config document, one upgrade story. A template is signed, versioned and immutable; an installed agent is a pinned reference plus an overlay, with per-path ownership tracking who last set each field.
A manifest declares required capabilities; it never grants them. Five paths are sealed by a database constraint: capabilities, leash ceiling, egress, connector auth, and the surface flag. The worst a malicious template can do is ask for a capability nobody holds, and then its tools are simply absent.
Install proves it works. The wizard runs the template's golden set through the real gate twice — once as the installing admin, once as a deliberately low-privilege fixture user — catching both installed-but-dead and works-for-the-admin-only. Missing connectors do not block install; the agent pins to Shadow with an amber badge, and an empty catalogue raises a hard error rather than letting the model answer from general knowledge.
| Role templates | Operational templates |
|---|---|
| Business Analyst · Pre-Sales · Project Manager · UI Designer · UX Designer · HTML Developer · WordPress Developer · Laravel Developer · Shopify Developer · Content Uploader · Tester | Internal Helpdesk · Site Health Sentinel · Support Ticket Agent · Project Status Reporter · Capacity and Hours Analyst · Quote and Proposal Drafter · AR and Renewal Chaser · Accountant Agent · SEO Agent · SEM Agent · SMM Agent · Knowledge Gap Curator |
Ship four on day one — Internal Helpdesk, Site Health Sentinel, Support Ticket Agent and Project Status Reporter — because they have the cheapest connectors and produce visibly correct answers immediately. The role templates land in stage 2 once their skills exist.
Three carry a manifest-level Shadow pin regardless of promotion criteria. AR and Renewal Chaser and Accountant Agent because they touch money, and the Accountant additionally because reconciliation errors are found weeks later rather than immediately. SEM Agent because ad spend is the one place an autonomous mistake bills you by the hour — it reads and drafts, and a human commits every budget change. SEO and SMM are read-and-draft by default too, with publishing as a separate grant.
Agents that learn. Learning writes to a reviewable delta queue — typed, evidenced, marked machine-authored — so what an agent taught itself stays permanently separable from what a client configured. A department lead approves or rejects. Nothing changes silently.
| Skill | Tool | Connector | |
|---|---|---|---|
| Is | A procedure | An executable function | A deployment unit |
| Format | SKILL.md folder | typed definition | transport + credential |
| Grantable | No — loaded by an agent | Yes, the only one | Never |
| Import | GitHub, URL, upload | From a connector | Installed and configured |
Connectors support four transports, all normalising to the same entity-tagged typed contract, so the transport is invisible above the connector layer:
Two properties enforced regardless of transport: scope at connect (one Drive folder, not the whole Drive) and read-only by default, with write as a separate deliberate grant shown at connect time. Every tool declares whose identity it runs as — the requester's by default. Third-party servers are pinned by a hash of their full manifest including tool descriptions, so a silent redefinition fails closed on reconnect.
Skills import in a non-executable state until a named reviewer approves. Progressive disclosure keeps cost down: the agent sees names and descriptions, loading full instructions on demand. A skill's scripts run through one tool, run_skill_script, carrying the sandbox, the leash and output redaction like any other.
| Kind | Holds | Visible to | Lifetime |
|---|---|---|---|
| Session | The current conversation and working state | the asker | dies with the thread |
| Persistent | Facts about people and entities — the identity mappings in §9, preferences, recurring context | by scope | until changed |
| Adaptive | Procedural learning from outcomes: what worked on cases like this | the agent's scope | decays unless reinforced |
Build the entity registry first — deterministic, no model, and most of what staff mean by "it remembers". Two invariants: every memory carries the capability set that formed it and is re-checked at read time, so a fact learned by someone who could see contract values is never replayed to someone who cannot; and nothing is promoted to a wider scope without a human. Memory is never authoritative over the database.
Adopt no memory product. Mem0, Letta and Graphiti all partition by id and call it access control, and most drag in a second stateful database. Nothing third-party sits between the capability set and the row.
Not by fine-tuning, and not by writing to itself. But most learning is automatic. An earlier draft of this document required a human for everything, which is wrong: an approval queue nobody has time to work means no learning happens at all, and the control gets bypassed rather than followed.
| Tier | What | Why it is safe |
|---|---|---|
| 0 · silent | Session and working memory | Dies with the thread. Nothing persists. |
| 1 · automatic no human | User preferences · retrieval boosts and demotions · entity links above threshold with hard-identifier corroboration · every negative signal | Blast radius is one person or one ranking. A demotion can only narrow what is seen, so it is always safe to apply immediately. An entity link corroborated by a matching domain and registration number is not a guess. |
| 2 · earns it no human | Fast-path rules · procedural shortcuts using tools the agent already holds | Shadow first. The rule is computed alongside the real answer and not used. After it agrees with reality enough times, it promotes itself and you are told. The evidence is the approval. |
| 3 · gated human | Widening scope from personal to department to company · changing company knowledge · raising a leash · entity merges crossing a money boundary · anything adding a capability or tool | These are the only changes that can disclose something to someone new, or make an agent do something it could not do before. |
Notify, do not ask. Tiers 1 and 2 produce a weekly digest — "here are the eleven things the Maintenance agent learned, and what each was based on" — with one-click undo on any of them. Reading a digest and reversing one item is a fraction of the work of approving eleven, and it keeps the oversight.
The review queue has a budget. If tier 3 exceeds a handful of items a week, the thresholds are wrong and the fix is tuning them, not asking humans to work harder. A queue that grows is a design signal, and the console charts it.
Nothing here relies on someone being diligent, because the decay, contradiction and source-truth rules below mean a bad automatic item corrects itself without anyone acting.
What is observed. Explicit thumbs-up feedback is close to dead in internal tools, so the signals are implicit and captured from day one — which makes this a schema decision in stage 1, not a feature to add later:
| Signal | Means |
|---|---|
| The same question re-asked in different words within minutes | The first answer failed. The strongest negative signal available. |
| The answer copied out | It was useful. |
| A follow-up that contradicts | It was wrong, and the correction is in the follow-up. |
| Escalated to a human | Out of the agent's competence, and the human's resolution is the label. |
| A human took over an Assisted action | The leash is set too long for that action. |
| A ticket reopened within N days | The resolution was wrong, discovered late. |
| An approval rejected with a reason | The best-labelled data in the system, and the rarest. |
What gets learned is three different things, and separating them is what keeps this safe:
Yes, four ways, and this is deliberate — a system that only accumulates becomes confidently wrong about a company that has moved on.
Promoted knowledge also carries a review-by date, with a scheduled job opening re-verification tasks. A review date nothing reads is documentation, not a control — which is the same mistake as a circuit breaker with no scheduled caller.
Where a person sees and controls it: every agent has a Memory tab listing what it has learned, with the evidence, the confidence, the date and a delete button. A department admin sees the same for their department. Nothing is learned that a person cannot read back in plain language, and anything deleted there is gone from retrieval immediately.
A large task splits into subtasks, runs across agents in parallel, and merges into one answer. The discipline is in deciding when.
The model proposes; arithmetic decides. A deterministic partitioner builds a dependency graph — node weight is estimated cost, edge weight is shared context a split would duplicate — and a gate decides in pure arithmetic whether parallel beats serial. Fail any condition and the run collapses to a single loop, which is the default rather than the exception. Published measurements put naive fan-out at 1.4–1.6x cost for noise-level gains, and your own prior system measured 2.15x.
Every subtask carries a contract — objective, output schema, tool hints, scope predicate. A result that does not validate is a failed step, not a merge input. Necessary here because the redactor only works on typed data, so "return prose" is not a permitted subtask contract.
Delegation only narrows. A child's grant is computed in SQL as parent ∩ agent ∩ subtask, and a database trigger rejects any delegation row that is not a subset of its parent. The orchestrator has no verb for granting. Depth is capped at two; concurrency at eight per run.
Cross-department work falls out of the scope model: if the asker holds both scopes it is one plan with two filters; if they hold one, they get what they are entitled to plus a stated reason for the gap and a request-access route.
This is a core capability, not an optional extra. Agents navigate, log in, read the screen, fill forms, take multi-step decisions, verify the result and report back — on the many client systems that expose no usable API. Python strengthens this rather than compromising it: Playwright's Python binding is first-class and identical in API to the Node one, and browser-use, the most-starred browser-agent project in existence at 112,000 stars, is Python-native. The credential-injection pattern we adopt in §15 comes from reading its source.
That asymmetry between reading and writing is the reason for the staging, and it also offers a way to have the capability sooner. Read-only browsing can land in stage 4 or 5 — checking a client portal, reading a dashboard no API exposes, pulling a status page — because at roughly 70% success a failed read costs a retry and nothing else. Writes stay in stage 6 behind the full envelope, enforcer and verifier, because a wrongly-reported success corrupts a client's records. If browser work is a priority for you, splitting it that way gets the useful half early without shipping the dangerous half unguarded.
Three planes. A control plane holds the plan, the capability set and the target registry, and never reads a byte of page content — the component that can widen permissions cannot see the text that would ask it to. An untrusted runner in an ephemeral sandboxed container reads the page and holds no authority. A deterministic guard between them contains no model at all: policy compiled at container start and immutable after, an egress proxy the container cannot route around, credential leases, and a hash-chained audit.
The human approves an Envelope once — the plan and its declared writes, rendered as a readable diff in a Lark card — not step by step, which trains people to click through. Re-interruption happens only on envelope breach.
Four supporting mechanisms: the agent reads the accessibility tree, not screenshots (cheaper, less brittle, and a screenshot of a login page can contain the credential while an accessibility node cannot); credentials resolve by reference at the execution boundary, bound to the current origin, never entering a prompt or a log; egress is default-deny to the envelope's origins; and success is never the agent's own claim — a verifier builds its rubric from the goal before seeing the trajectory.
One rule removes most of the risk: the skill loader refuses to register a browser skill for any domain that has a registered API tool, unless the folder carries an explicit exception with a named approver and an expiry. Never attach the agent to a staff member's own logged-in browser profile.
Lark, Slack, Teams, WhatsApp, Telegram, a web widget, email and the API — one brain behind all of them. A gateway terminates each platform's wire protocol and does nothing else: no entitlements, no redaction, no policy.
Identity binds outward only. A nonce minted inside an authenticated web session is presented on the new channel. Never a code sent to a number that asked for one.
The group-chat problem is solved by moving when redaction runs. Keep the answer typed and unredacted briefly, then run the redactor at render time under each viewer's own entitlements. Every room gets a room envelope computed at the floor of everyone present, plus a per-viewer body delivered by whatever private mechanism the platform supports — Lark ephemeral cards, Slack ephemeral posts, Teams per-user refresh, and finally a link into the web app where the gate re-runs under the viewer's own session.
Async task results re-run the entitlement computation at send time. A task that takes twenty minutes outlives the world it was authorised in. Per-channel policy decides which sensitivity classes a channel may carry at all.
Tier, then an ordered chain, then a pinned deployment. Tier classification is deterministic — tool count, estimated context, lane, explicit user request, and whether the scope touches data with a residency rule. Never a model call, because that adds a round trip to every request and would let text inside a retrieved document influence which jurisdiction handles it.
| Tier | Chain | Fallback fires on |
|---|---|---|
| small | default → next model, same provider → next model → next provider | A closed set only: connection error, timeout, 429, 5xx, context exceeded. Never "the answer looked weak". Open question: this set originally also carried content-policy refusal. The implementation excludes it, on the grounds that a refusal is a property of the request rather than of the provider's health, so the next rung reproduces it at full cost, and a rung that does answer has shopped for a "yes". Awaiting a decision; see Needs Rupash. |
| main | same shape, heavier default | |
| heavy | same shape, heaviest default |
Each attempt appends a row, so the executed chain is reconstructable from one join. The breaker is a fail ratio over a window, not failures per minute — at 0.1 requests per second a per-minute threshold is nearly unreachable and a dead provider stays in rotation for an hour. A background prober keeps an idle-but-broken provider visible. Cooldown backs off with jitter. Alert on fallback depth, not final failure.
Residency is a hard rule: a scope carrying a residency constraint cannot route outside it, and the chain skips those rungs rather than degrading quietly.
Verz Studio lets a department admin or member create agents without code. Agent authoring is a form composer — identity, persona, knowledge, skills, tools, leash, tests — because control flow is model-driven at run time and the catalogue is projected per request, so an edge someone draws is a promise the runtime would have to break.
The canvas survives where it earns its place: a read-only trace graph of what actually happened on a completed run, which is what people usually want a canvas for, and a bounded procedure canvas that authors a SKILL.md with a small node set, scope predicates as the only conditional grammar, and no code node.
Rehearsal runs a draft against the real gate, leash and redactor, with tool calls replaying recorded fixtures so a test never fires a real side effect. Publish is gated by system-authored permission canaries; author-written tests stay advisory so the gate cannot decay into a rubber stamp. Widening grants demotes to Shadow and needs a second approver; editing instructions does not.
A fair challenge, and the answer is that we use a great deal of it — at the library level rather than adopting a whole builder product. All verified 4 September 2026.
| Need | Library | Licence | Health |
|---|---|---|---|
| Canvas: trace graph and procedure editor | React Flow (xyflow) | MIT | 38.3k stars, pushed 2 Sep |
| Forms generated from JSON Schema | react-jsonschema-form | Apache-2.0 | 15.9k stars, pushed 3 Sep |
| Tables, filtering, the console grids | TanStack Table | MIT | 28.4k stars, pushed 31 Aug |
| Graph state and checkpointing behind the canvas | LangGraph | MIT | 41.0k stars, pushed 3 Sep |
The install wizard and every template configuration screen are generated from the manifest's JSON Schema rather than hand-built, which is why adding a template field costs no frontend work. That is react-jsonschema-form doing the job an entire in-house form framework would otherwise do.
Langflow is MIT, 154,000 stars and pushed daily. The question deserves a real answer rather than a preference, because adopting it would visibly save frontend work.
| Adopt Langflow | Weeks | Build the composer | Weeks |
|---|---|---|---|
| Install and run it | <1 | Form composer, generated from the template JSON Schema | 3–4 |
| Reconcile its own user model with Keycloak | 1–2 | Read-only trace graph on React Flow | 1–2 |
| Disable code components and prove no bypass | 1+ | Rehearsal, publish gate, permission canaries | 2–3 |
| Wrap execution so every tool call still passes our gate | 3–4 | Bounded procedure canvas (deferrable) | 3–4 |
| Build the publish gate and canaries anyway — Langflow has none | 2–3 | — | — |
| Total | 8–10 | Total, MVP 6–9 | 10–13 |
This is not caution on my part. Langflow's own security documentation states:
For a product whose entire value is field-level permission enforcement, adopting a builder whose vendor says its access controls are not security controls is not a trade-off — it is a contradiction. A department admin who can write Python in a flow reads the database directly and the gate never runs. Note also that its unauthenticated remote-code-execution flaw in 2025 was actively exploited in the wild, which is what that architecture makes possible.
Disabling code components is possible, but it removes the feature that makes Langflow worth adopting, and leaves you operating a large application for its canvas alone.
| Langflow as the agent builder | Our composer | |
|---|---|---|
| Time to a demo | days | weeks |
| Time to something safe to sell | 8–10 weeks | 6–9 weeks to MVP |
| Permission fit | code nodes bypass the gate entirely | native — it only writes grants and leashes |
| Ops cost per install | +1 app, +1 datastore, +1 upgrade treadmill, forever, across every client | none |
| Who can actually use it | Technical staff. Canvases consistently end up used by developers. | Non-technical, because it is a form with sensible defaults |
| Flexibility | draw anything | bounded — this is a real limitation |
| Scalability at our load | Fine | Fine. Neither is a bottleneck at 0.1 req/s. |
The honest concession: our composer is less flexible. Someone who wants a genuinely custom graph cannot draw one, and the bounded procedure canvas only partly covers that. If a client's requirement is "let our people draw arbitrary automations", that requirement is real and the answer is the hybrid below rather than pretending the form covers it.
Deterministic automation — a trigger, then steps, with no model deciding control flow — is exactly what a canvas is good at, and it carries none of the objections above because the flow calls our tools through the API and the gate still runs on every call.
So ship it as an optional container, sandboxed with allowlisted egress and no database credentials, on the extension-point list so it is a configuration choice rather than a fork. For that job Activepieces (MIT core) fits better than Langflow, and n8n is available where a client already runs one.
The division that results: model-driven agent behaviour is composed in a form, because a drawn edge would be a lie about what the runtime does when the catalogue is projected per request. Deterministic automation is drawn on a canvas, because there the drawing is the truth.
| Group | Screens |
|---|---|
| Operate | Live runs · Model matrix and provider health · Connector health · Knowledge coverage and staleness · Queue and automations |
| Govern | People and grants · Scopes and departments · Agents and leash state with promotion history · Skills and templates with review queue · Audit |
| Report | Questions and gaps · Usage and cost · Quality and canaries |
The console is an ordinary client of the gate: its reads are tool calls with required capabilities, its rows are entity-tagged so the redactor applies to reports as to answers, and a report's audience is intersected with the caller's scope, never unioned. Super Admin filters by department and user; Department Admin sees the same shape bounded to their scope.
Two stores split by classification — a metadata ledger in Postgres with long retention and no content, and a payload store for thirty days behind a separate role with an audit row written before every read. Because the console holds no payloads of its own, observability cannot become a bypass around redaction. Honest counting is enforced in the type system: every request declares its traffic class at ingress with no default, so a new channel cannot compile without saying what kind of traffic it produces.
The console is not the whole product, and a screen inventory written late is a screen inventory that gets built twice. There are three authored surfaces plus the chat channels, and they differ by who signs in rather than by what they can reach — entitlement decides that identically in all four.
| Surface | Who | What it is for | Screens |
|---|---|---|---|
| Admin console | Super Admin, Department Admin | Governance and inventory: every agent, skill, knowledge item, connector, grant and learning in scope | 13 |
| Agent workspace | Whoever may open a given agent | Assembly: one agent, and what is attached to it | 7 tabs |
| Member application | Every member, signed in on the web | What the system holds about you, and your approvals | 9 |
| Channels | Everyone | Asking, answering, approving in Lark and WhatsApp | — |
The two admin roles share one console build with a scope filter rather than two products. A Department Admin's console is the Super Admin's console with the scope chip pinned and the cross-department rows absent — which is a query predicate, not a second frontend. Knowledge and Learning appear in both: a Department Admin sees their own department's rows, the Super Admin sees every row in the company, because coverage across departments cannot be judged from inside one of them.
One distinction the console must hold and most products collapse: a Super Admin sees the complete inventory by role — every item, its owner, scope, freshness and audience. Opening the contents of another department's document is a separate capability, granted by default in a single-tenant install, revocable, and written to the audit ledger on every use. Existence, configuration and content are three planes of visibility, and role governs the first two while entitlement governs the third.
The member application and the agent workspace were both missing from the first console specification, and the second omission is the more expensive one. An agent composes eleven things, each stored as a reference with a pinned version rather than an inline copy, so a fix reaches every instance when someone promotes the version and a repository push on its own changes nothing that is live:
| Attached | Stored as | Who may change it |
|---|---|---|
| Persona and role | versioned text | owner |
| Knowledge | a scope predicate, never a document list | owner, within their own scope |
| Connectors | references; ungranted ones show as requested | department admin |
| Skills | pinned versions from the approved library only | owner, after review |
| Channels | references, intersected with the ceiling | department admin |
| Model policy | primary and fallback chain | super admin |
| Ceiling | the widest entitlement any run may reach | department admin |
| Leash | a rung per target and scope | department admin; money boundaries need two |
| Memory | readable text with a diff per revision | owner; the subject may delete their own |
| Automations | owned by the agent, run on a named principal | owner |
| Artifacts | append-only, carrying the producing run | nobody edits; supersede or archive |
Availability and ceiling are two blocks that must never merge in the interface. Availability answers who can find and invoke this agent; the ceiling answers what any run of it may reach. Adding a department to Availability lets forty more people find the agent and gives them no data they could not already reach, because a run still resolves to E(caller) ∩ ceiling. Notion and Dust both ship the opposite — the creator's access travels with the agent — and the reason it is worth building the harder way is that theirs turns every shared assistant into an access-escalation path. The workspace therefore carries a preview-as-a-person control that runs the real gate rather than an estimator, because a preview with its own logic is the component most likely to lie.
Knowledge and assets are inputs. Until now there was nowhere for output to live — every generated report, deck, export and image had no home in the model, which is a gap AnyGen's interface makes obvious by having solved it. An artifact is not a sixth governed noun, because nobody is granted one; it is an object class with a single rule. An artifact inherits the entitlement of the run that produced it. Field-level redaction is applied at production time rather than at read time, so an artifact can never contain more than its caller could see, and re-downloading re-checks the requester rather than trusting the original link. Retention follows the classification of the most sensitive input that fed it. Artifacts are superseded and archived, never edited, so provenance stays intact.
By layer, so every band in §2 has a named component. Health verified 4 September 2026.
| Layer | Choice | Licence | Why |
|---|---|---|---|
| Channels | Own adapters, harvesting RAGFlow's Go channel code | Apache-2.0 | Tested Lark, WhatsApp, Telegram, Teams adapters already exist and are liftable. |
| Gate and redaction | Ours | — | The product. Never delegated. ~2,000 lines. |
| Orchestrator | LangGraph + Pydantic AI | MIT | A library, not a service, so the tool loop and permission checks stay ours. See §3. |
| Durable execution | Hatchet (Postgres-backed), or Temporal at scale | Apache-2.0 / MIT | Behind our own adapter so the choice is reversible in one file. |
| Connectors | MCP SDK + our REST, DB and custom adapters | MIT | Four transports, one typed contract. |
| RAG / retrieval | PostgreSQL FTS + pgvector, fused | PostgreSQL | No second datastore. Scope filter inside the query. |
| Document parsing | Docling, Tika fallback | MIT | Layout-aware; tables in PDF contracts survive. |
| Embeddings | Qwen3-Embedding-0.6B, local | Apache-2.0 | Off the hot path; keeps document content out of a third party. |
| Entity resolution | Splink offline + pg_trgm / fuzzystrmatch online | MIT / PostgreSQL | Calibrate weekly, evaluate in SQL at ~1ms. No ML dependency in the request path. |
| Memory | Ours, on Postgres | — | Nothing third-party between the capability set and the row. |
| Cache | Valkey + Postgres | BSD-3 | Redis relicensed. Entitlement-keyed, never semantic. |
| Model gateway | LiteLLM SDK | MIT core | Driver only, never the routing authority and never the proxy. Pin ≥1.83.10. |
| Routing and matrix | Ours, config in Postgres | — | Deterministic tier classification; residency as a hard rule. |
| Visual builder | React + Vite + React Flow, our composer | MIT | Form-first for agents; canvas for traces and procedures. |
| Browser | Playwright + playwright-mcp | Apache-2.0 | Accessibility-tree-first. Healthiest maintenance signal in the evaluation. |
| Sandbox | gVisor, Kata where KVM exists | Apache-2.0 | No /dev/kvm needed, which many SME VPS lack. |
| Egress control | mitmproxy | MIT | Sole route out of the runner namespace. |
| Guardrails | Presidio + GLiNER, signal only | MIT | Never an authorisation boundary — its own FAQ disclaims recall. |
| Identity | Keycloak | Apache-2.0 | SAML and OIDC to the client's IdP. Zitadel relicensed to AGPL. |
| Secrets | OpenBao | MPL-2.0 | Leases scoped to (agent, action, scope, run). |
| Tracing | Langfuse — mask client-side | MIT core | Six services with mandatory ClickHouse. Its docs state events land in blob storage before masking, so never rely on server-side masking. |
| Evaluation | promptfoo, driven through our gate | MIT | So an eval proves something about redaction, not about a prompt. |
| Object storage | SeaweedFS | Apache-2.0 | Assets and session recordings. MinIO is archived. |
| Database | PostgreSQL 18 + pgvector + PgBouncer | PostgreSQL | One store for rows, documents, vectors, queue, audit and memory. |
| Backup | pgBackRest + restic | MIT / BSD-2 | Test the restore, not the backup. |
| Proxy and TLS | Traefik, via Coolify | MIT / Apache-2.0 | Coolify configures it from the deployed resources and issues certificates. Caddy is the fallback where Coolify is not used. |
| Deployment | Coolify + Docker Compose | Apache-2.0 | Already in place. Compose files are the source of truth; Ansible only where Coolify is absent. |
Rejected: LLM Guard, MinIO, Daytona and Flowise (all archived, all still widely recommended); Redis and Zitadel and ParadeDB (licence); E2B (needs a cloud account plus Nomad and Consul for ten concurrent requests); Mem0, Letta and Graphiti (partition by id and call it access control); Onyx, Dust and AnythingLLM as a base (all enforce at document level — retrofitting field-level redaction is a rewrite wearing a fork's clothes).
On LangGraph specifically: the library is MIT and excellent, but langgraph-api — the server providing persistence, queues and crash recovery — is Elastic-2.0 and needs a commercial key in production. If you choose Python and want LangGraph, budget building that driver over the open-source checkpointer, and keep the tool registry in one place both the graph and the redactor read.
| Small · 50 staff | Medium · 150 staff | Large · 300 staff | |
|---|---|---|---|
| Questions/day | ~300–1,000 | ~1,500–3,000 | ~3,000–6,000 |
| Machine | 8 vCPU · 32 GB · 500 GB NVMe | 16 vCPU · 64 GB · 1 TB | 2 boxes: app 16/64, db 16/64/2 TB |
| shared_buffers | 8 GB | 16 GB | 24 GB |
| work_mem | 16 MB | 32 MB | 32 MB |
| PgBouncer pool | 15 | 20 | 30 |
| Worker concurrency | 4 | 6 | 12 |
| Browser memory cap | 8 GB · ~20 contexts | 8 GB · ~20 | 16 GB · ~40 |
| Disk growth/year | ~15 GB | ~40 GB | ~90 GB |
| Backup target | ~120 GB | ~350 GB | ~750 GB |
Two Postgres pools, not one. PgBouncer in transaction mode for the app, and a separate session-mode pool for the job worker, because transaction pooling breaks the LISTEN/NOTIFY the worker depends on. Set autovacuum_work_mem explicitly or autovacuum workers inherit maintenance_work_mem and multiply it.
Latency budget. Fast lane under 1.5s, realistically 5–15ms. Answer lane under 8s with one live fetch on an 800ms timeout. Singapore to a source API is 200–600ms before their processing. Provider SDK defaults left alone can be a 10-minute timeout with 2 retries — override to 25s and 1 retry on the answer lane.
What breaks first, in order: document parsing memory during bulk ingestion, Postgres connections, then tracing. None of them is request throughput.
Deployment profiles. Lite is one box for a 50-person client: Postgres, app, worker, Caddy, Keycloak, ledger only. Full adds tracing, payload store, object storage, browser runner and automation. Same code, different compose file.
Additions, not removals. Ordered by impact against effort at your current volume.
| Improvement | Why it pays |
|---|---|
| Measure before optimising | The predecessor's own figures do not reconcile (2.4 + 33.7 ≠ 31.2), and output tokens were never measured. Instrument the thinking-versus-output split, TTFT, per-stage timings and 429 counts in week one. Every number below is a row update afterwards, not a release. |
| Static tool prefix, projected catalogue | Declare the byte-identical tool union in the cached prefix and emit only the entitled subset after it. The provider cache is then shared across all 126 people because the cached bytes contain no per-user data. That is a security property as well as a cost one, and it is expensive to retrofit. |
| Grow the fast lane | The only measured sub-second win available. New fast-path rules must be data rows, not code, so a department admin can add one. Measure its share excluding machine traffic. |
| Collapse guard checkpoints | Three serial screens cost roughly 700ms — the largest fixed item in the answer budget. At your load the scanner runs at about 2.5% utilisation, so this is a latency problem, not a throughput one. Collapse the checkpoints; do not buy GPUs. |
| Plan cache | Stores tool ids, never results. A cached plan is a hint filtered through catalogue projection at execution, never an authorisation. This is the round-collapse mechanism. |
| Named progress steps | Perceived latency tracks visible progress, not elapsed time. Label steps in the user's language, and remember step labels are part of the security surface — "reading finance ledger" leaks the ledger's existence. |
| Improvement | Trigger |
|---|---|
| Answer cache | Only once source epochs and a policy epoch exist. Without the policy epoch, tightening a field policy leaves a window where a cached answer keeps disclosing a field that was just revoked. |
| Read replica for the console | When console queries start affecting answer latency. Not before. |
| Ledger partitioning | Around 10x current volume. |
| Materialised views for reports | When a report takes over two seconds. |
| Local model hosting | A contractual residency requirement, never cost or speed. |
Two caches are forbidden by construction. Semantic answer caching, because similarity is not entitlement equality. And any cache keyed without the entitlement hash.
Flexibility comes from four seams: sixteen versioned extension points so a new connector or channel ships without a core release; every vendor behind our own adapter so swapping costs one file; configuration as data in Postgres rather than code, so tuning is not a deploy; and deployment profiles so the same code runs lite or full.
The honest ceiling. Tool and database time was 2.4s against a 27.9s median — 8% of the budget. At a 6s target it becomes 40%. Every model-side lever above stops mattering at that point, and what attacks it is the projection, the fast lane and parallel tool execution. That is the real limit of this design, and it arrives long before request throughput does.
This is Docker Compose on Linux. Hetzner, DigitalOcean, Vultr, Linode, OVH, a local Singapore provider or the client's own hardware all work, and none is meaningfully better than another for this workload. AWS works too if the client is already there, but it is more expensive for the same specification and the client-hosted premise means you would not be using the managed services that justify its price.
Three things matter more than the provider:
/dev/kvm, if you want to promise kernel-level sandbox isolation. Most budget VPS plans do not expose nested virtualisation. Without it, gVisor still works and is what we default to; Kata is the upgrade that needs it. Check this per client before promising anything about isolation.Caddy handles TLS automatically, so nothing is blocked without Cloudflare. It earns its place in exactly one case and is unnecessary in another.
| Situation | Verdict |
|---|---|
| Public web widget on a client's marketing site | use it DDoS protection, WAF and origin-IP hiding on the only unauthenticated surface in the system. |
| Internal only — Lark plus the console behind a VPN | skip it Nothing is publicly reachable, so there is nothing to shield. |
| Any deployment, if you want no inbound ports at all | Cloudflare Tunnel The box makes an outbound connection and accepts no inbound traffic. A genuinely strong posture for a server you support remotely, and worth defaulting to. |
One consequence to document rather than discover: with Cloudflare in front, TLS terminates at their edge. For a client with a data-residency clause, confirm that is acceptable before enabling it.
Coolify is Apache-2.0, actively maintained, and covers a real slice of this. It changes three decisions in this document.
| Coolify gives you | Effect on the design |
|---|---|
| Traefik with automatic TLS | Caddy is no longer needed. Coolify configures Traefik from the deployed resources and issues certificates itself. One fewer thing to run and one fewer thing to explain. |
| Docker Compose as the source of truth | Exactly our packaging. The lite and full profiles become two compose files Coolify deploys, with no wrapper. |
| Scheduled database backups to S3, cron-configurable | Covers the backup half of §23. It does not cover restore verification, which stays ours. |
| Per-resource environment variables | Fine for configuration. Not for runtime secrets — connector credentials and browser session material stay in OpenBao, because those need per-run leases and an audit trail, not an env var. |
| Multi-server management | The two-box upgrade path in §25 becomes a Coolify configuration rather than a migration. |
Two caveats worth knowing before you rely on it. Coolify's own documentation advises against running it on the same server as the workloads it manages, because heavy application load can starve the control panel — for your own install that is a judgement call, and for client installs it is an argument for Coolify managing them remotely rather than sitting on each box. And budget its footprint: roughly 2 GB for a single application, 4 GB alongside databases, which comes off the figures in §21.
One private repository. GitHub Actions runs lint, type checks, tests, a migration check and the permission canaries, then builds and signs a container image and publishes it to a registry.
That single choice removes the two worst outcomes in a multi-client deployment pipeline: a compromised CI account reaching every client's server, and a client's secrets sitting in a repository. Each install runs a small agent that polls the registry for the tag it is pinned to and applies the update with drain.
| Concern | How it works |
|---|---|
| Per-client versions | Each install pins a release tag. Client A can sit on 1.4 while client B takes 1.6, and an urgent fix ships to one without forcing it on all. |
| Configuration | One Ansible variables file per install, in a separate private repo. Never in the application repo. |
| Secrets | Minted per install, held in that install's OpenBao, never copied between clients and never in git. |
| Migrations | Run before the new image takes traffic, and must remain backward-compatible for one release so rollback is real rather than theoretical. |
| Rollback | Re-pin the previous tag. Because migrations are backward-compatible for one version, this is a minute rather than an incident. |
The install checklist exists because a copied deployment silently carries four things: platform project files, temporary CLI state, git remotes, and scheduled jobs. Every one of those has caused a production incident in this codebase's predecessor. They are checked on every install, not remembered.
| What | Tool | Schedule |
|---|---|---|
| Database | pgBackRest | Continuous WAL archiving · daily incremental · weekly full |
| Object store and config | restic | Daily, deduplicated |
| Destination | off-host, always | A backup on the same disk is not a backup |
| Encryption | at rest, client-held key | We can restore it for them; we cannot read it without them |
| Retention | — | 30 daily, 12 weekly, 12 monthly |
The part everyone skips, and the reason this is a module: a weekly job restores the most recent backup into a scratch container, runs migrations, executes a smoke query and a permission canary, records the elapsed time as the measured RTO, then destroys the container. A backup that has never been restored is a hope, and the elapsed time is the only honest answer to "how long would recovery take".
The console's recovery panel shows last backup, last verified restore, measured RTO against the profile's target, and offers a one-click drill. It alerts when backup age exceeds the RPO for that profile, or when a verification fails — which is a louder alarm than a failed backup, because it means the backups you have may not work.
A user can build an agent that fans out, calls six tools, retrieves forty chunks and runs on the heaviest model. Nothing in the design so far stops them running it two hundred times. Budgets and concurrency are the same subsystem, so they are specified together.
| Level | Set by | Typical control |
|---|---|---|
| Company | Super Admin | Monthly token and currency ceiling. A hard stop, and an alert at 70 and 90 per cent. |
| Department | Super Admin, spent by the Department Admin | A share of the company budget. Departments cannot borrow from each other without an explicit transfer. |
| User | Department Admin | A daily and monthly allowance, with a fair-share cap so one person cannot drain the department in an afternoon. |
| Agent | Whoever publishes it | A per-run ceiling and a per-day ceiling. This is the control that matters most, because an agent is where cost is designed in. |
Every level is a row, editable in the console, versioned and audited. None is a constant in code.
Before: the router estimates a request's cost from its lane, tier, expected tool count and retrieval size. If the estimate exceeds any remaining budget in the chain, the request is refused or degraded before a token is spent. After: actual usage is recorded against principal, department, agent, model and lane. Estimates are compared to actuals and the estimator is corrected, because a cost control based on a bad estimate is theatre.
On breach, degrade rather than fail wherever it is safe: drop to a cheaper model tier, then disable fan-out, then queue the task lane, then refuse with a message naming which budget was hit and who can raise it. A hard refusal on the first breach makes people distrust the system; silent degradation without telling them makes them distrust the answers. Say which one happened.
An agent's cost is designed in when it is published, not when it is run, so that is where the control belongs. The publish gate in §18 shows a measured cost per run from the rehearsal fixtures — not an estimate — and publishing an agent whose median run exceeds the department's per-run ceiling requires the Department Admin's approval. Fan-out is a separate grant: an agent may not spawn parallel workers unless explicitly permitted, because that is where a single question becomes fifteen model calls.
Because the client holds their own provider keys, these numbers are theirs and reconcilable against the provider's own invoice. That is a genuine advantage over any credit-based hosted product, and worth stating in a proposal.
An independent review made a fair criticism of an earlier draft: it was stronger on the security control plane than on operational resilience. That is accurate. This section closes it, and most of what follows is adopted from that review.
The design had per-run limits — eight concurrent subtasks, depth two — and no global limit. Twenty users each running eight subtasks is a hundred and sixty concurrent operations against a machine sized for ten. Every resource gets a global budget, enforced centrally before work starts, not by each subsystem independently consuming until Postgres or memory gives out.
| Budgeted globally | Under pressure |
|---|---|
| concurrent model calls · concurrent source calls per connector · browser sessions · long-running tasks · document jobs · embedding jobs · tokens per minute | FAST still runs · ANSWER queues briefly · TASK queues · browser queues separately · ingestion throttles · reports deprioritise |
All of these are configuration rows, not constants. Three workload classes share the database with separate pools and budgets: interactive (the request path), background (tasks and agents), batch (ingestion, re-indexing, reporting). Six classes would be over-engineering at this size; one is what causes an ingestion run to make the chat slow.
This was a genuine gap, and it matters most exactly where the system is most ambitious — browser writes and connector side effects. Retrying blindly repeats the action; not retrying loses it. The protocol's own recent revision removed message redelivery, so connector-side idempotency is mandatory rather than optional.
Every side-effecting operation carries an idempotency key and moves through explicit states: PENDING → SENT → UNKNOWN → VERIFYING → SUCCEEDED | FAILED. UNKNOWN is the important one, because it is the honest state after a crash, and it resolves by verification against the target system rather than by retry. Every connector must therefore implement a read-back that can answer "did operation X land?". A connector that cannot is restricted to read-only tools, and that restriction is declared in its manifest rather than discovered later.
The earlier constraint of "at most one live fetch per question" was too rigid — some questions genuinely need the CRM, the helpdesk and the ledger. The correct formulation is one critical path with parallel I/O: independent source calls fan out concurrently and the latency budget covers the slowest, not the sum. The per-source and global call budgets still apply, so parallelism cannot be used to evade the rate ceilings in §8. The dependency graph already built for multi-agent work is reused here for connector calls.
A token bucket, circuit breaker and retry policy sit in front of every external system, owned by the connector layer rather than by callers. Twenty agent runs must not independently decide to call the same API twenty times. Per connector we track requests per second and minute, concurrency, 429 rate, latency and error rate — and the ceilings from §8 are configured here, not remembered.
Browser work is the most expensive subsystem, so it gets its own scheduler: global concurrency, memory and CPU limits, per-domain limits so one misbehaving automation cannot occupy every slot, per-agent limits, and a global kill switch.
| Metric | Target | Metric | Target |
|---|---|---|---|
| FAST p50 / p95 | <100ms / <500ms | Permission decision p95 | <50ms |
| ANSWER p50 / p95 | <4s / <8s | Redaction p95 | <20ms |
| TASK acknowledgement | <1s | Connector timeout | 5s default |
| Successful request rate | >99.5% | Unintended disclosure | zero tolerated |
Every request records: request and trace id, principal, agent version, policy epoch, entitlement hash, lane, model, provider, time to first token, input and output tokens, tool count and latency, connector, cache hit or miss, redaction count, fallback count, retry count, final status. This is mandatory production telemetry, not optimisation instrumentation — the numbers above are provisional until stage 1 measures them, because the predecessor system's own figures did not reconcile.
| Profile | RPO | RTO | Means |
|---|---|---|---|
| Lite | ≤ 24h | ≤ 8h | Nightly backup, off-host copy, documented rebuild |
| Full | ≤ 4h | ≤ 2h | WAL archiving, object-store replication, rehearsed restore |
| HA | ≤ 15m | ≤ 30m | Streaming replica, second box, tested failover |
"Backups work" tells a client nothing about resilience. These numbers do, they belong in the proposal, and backup frequency follows from them rather than the reverse. The single box remains the default and remains a large failure domain — the mitigation is that the two-box split is designed now and built when triggered: application and browser on one, database and object store on the other, with no application change required.
Before implementation goes far, the team produces a failure-mode matrix: for every component, what failure looks like, the user impact, the recovery path, and the data risk. Every operation is then classified as safe to retry, unsafe to retry, retry only after verification, or requires a human.
The tests that matter are not the happy paths. Kill Postgres, the model provider, a connector mid-call, the browser, the worker mid-task. Return malformed JSON and 429s. Fill the disk. Change a policy during an active task; disable a user during one. And the decisive one: kill the worker immediately after every side-effect boundary and verify the system recovers without repeating the action. That is how durable execution is proven rather than assumed.
| Stage | Ships | Done when |
|---|---|---|
| 1 · Gate 5–7 wks | Schema and row-level security, Keycloak, capability tables, tool registry, catalogue projection, redaction walker, row plane, entity registry, one Lark channel, audit, backup with a tested restore, measurement layer | Twenty real questions from five real people, answered correctly, permission decision explainable on each, one restore drill completed |
| 2 · Execution kernel and agents 5–6 wks | Kernel first: job model, idempotency keys and operation states, durable checkpoints, cancellation, timeouts, admission control, per-connector rate limiting, budget tables. Then agents: template model, four day-one templates, install wizard with golden sets, skills import with review, form composer, leash table | A department admin installs an agent and it answers correctly in minutes — and a worker killed mid-task recovers without repeating the action |
| 3 · Trust 3–4 wks | Ledger and payload split, Operate screens, permission canaries, caches, model matrix and breaker, fast lane | A model can be swapped without fear; you know where time and money go |
| 4 · Breadth 4–6 wks | Document plane, more connectors, knowledge centre, second department, second and third channels, Govern and Report screens, joiner-mover-leaver, role templates | Two departments in real use across two channels |
| 5 · Doing 4–6 wks | Automations, approval cards, multi-agent decomposition, Shadow and Assisted only. Plus read-only browsing: the sandboxed runner, egress proxy and credential leases, restricted to navigation and extraction | The brain does work; and it can read a client portal that has no API |
| 6 · Hands 4–6 wks | Browser writes: envelope compiler, enforcer, out-of-band verifier, session recording, approval diffs. The runner already exists from stage 5 | One real write against one target, behind Assisted |
| 7 · Judgement on trigger | Confidence scoring, Autonomous rung, circuit breaker with a scheduled caller, memory promotion | Only after stages 1–6 generate real outcome labels |
Roughly seven months, usable at the end of every stage. Three sequencing rules worth defending in review:
/dev/kvm, which decides what sandbox isolation can be promised.