Security

Every read is checked.
Every check is recorded.

Each agent acts with its own token, limited to the parts you shared. This page shows how that check works, what is recorded, and the limits we have not solved yet.

What we measured

Tested on deployed builds, not a demo.

  • Each agent sees only what its person may see. An agent whose access is revoked, expired or removed is refused on its next call.

    No leaks across the 66 tools and routes found that can refuse a request.

    Build ec524755 · 17 Oct 2026 · Not yet re-run on the current build.

  • When two agents write the same fact, one version wins and the other writer is told which one.

    Every read path shows the one current version.

    Build 2ca31c57 · 20 Oct 2026

  • Agents see each other's changes within seconds, and a task handed off mid run arrives with its last note and claim.

    Measured under normal, concurrent and failure load. A later run, on Oct 29, missed the 2-second bar for working notes with six agents posting at once; the chart below shows it.

    Build 2ca31c57 · 20 Oct 2026

  • Disconnect a source and its content stops answering.

    Measured on GitHub.

    Build 2ca31c57 · 20 Oct 2026

  • Erase a person and every live store stops returning them, with a record of the erasure.

    Erasure that survives a backup restore is not measured yet.

    Build 2ca31c57 · 20 Oct 2026

  • Each answer and refusal from the access check is recorded with who asked, and the app itself cannot edit or delete those records.

    A request with a missing or unknown token is turned away before the check, without a record (there is no org to record it against); an expired or revoked token is turned away with a capability.refused record.

    Build ec524755 · 17 Oct 2026 · Not yet re-run on the current build.

  • A source's own sharing list does not come with it. Connecting a source more widely than it was shared needs an explicit confirmation, and that choice is recorded.

    A connect without the confirmation is refused and changes nothing.

    Build ec524755 · 17 Oct 2026 · Not yet re-run on the current build.

Measured on willder.ai, Oct 2026

How fast, on the hosted service.

Timed from the client, through the public API and /api/mcp, on one deployment on one day. These are measurements, not guaranteed latencies.

Task-start brief

The block of rules, decisions and recent writes on a session's first tool result has a bar: it may add at most 200 ms to that call at p95. Each run opens 40 sessions one at a time and takes the first call minus the second, as the server reports it; p95 is the 38th smallest.

Time the task-start brief added to a session's first call, p95. Grouped horizontal bars, milliseconds, before and after the fix, with the 200 ms bar marked. Team scope run 1: 268.6 before, 88.0 after. Team scope run 2: 210.3 before, 66.0 after. Demo scope: 145.8 before, 31.8 after.Time the task-start brief added to a session's first call, p95. Grouped horizontal bars, milliseconds, before and after the fix, with the 200 ms bar marked. Team scope run 1: 268.6 before, 88.0 after. Team scope run 2: 210.3 before, 66.0 after. Demo scope: 145.8 before, 31.8 after.Time the task-start brief added to a session's first call, p95. Grouped horizontal bars, milliseconds, before and after the fix, with the 200 ms bar marked. Team scope run 1: 268.6 before, 88.0 after. Team scope run 2: 210.3 before, 66.0 after. Demo scope: 145.8 before, 31.8 after.Time the task-start brief added to a session's first call, p95. Grouped horizontal bars, milliseconds, before and after the fix, with the 200 ms bar marked. Team scope run 1: 268.6 before, 88.0 after. Team scope run 2: 210.3 before, 66.0 after. Demo scope: 145.8 before, 31.8 after.
After a fix to one database read, the brief added a p95 of 88.0 ms, 66.0 ms, 31.8 ms across the three runs, down from 268.6 ms, 210.3 ms, 145.8 ms. (measured on willder.ai, Oct 29, 2026)

Write-to-read

From just before one agent sends a write until another agent's read returns it, so the network both ways is inside the number. Three loads: one writer at a time, six at once, and every write retried with one writer's token revoked halfway.

Write-to-read latency on production, by load. Grouped horizontal bars, milliseconds, median and p95 for three loads. One writer at a time, 60 samples: median 538, p95 881. Six writers at once, 60 samples: median 688, p95 1292. Every write retried with one writer revoked halfway, 66 samples: median 834, p95 1588.Write-to-read latency on production, by load. Grouped horizontal bars, milliseconds, median and p95 for three loads. One writer at a time, 60 samples: median 538, p95 881. Six writers at once, 60 samples: median 688, p95 1292. Every write retried with one writer revoked halfway, 66 samples: median 834, p95 1588.Write-to-read latency on production, by load. Grouped horizontal bars, milliseconds, median and p95 for three loads. One writer at a time, 60 samples: median 538, p95 881. Six writers at once, 60 samples: median 688, p95 1292. Every write retried with one writer revoked halfway, 66 samples: median 834, p95 1588.Write-to-read latency on production, by load. Grouped horizontal bars, milliseconds, median and p95 for three loads. One writer at a time, 60 samples: median 538, p95 881. Six writers at once, 60 samples: median 688, p95 1292. Every write retried with one writer revoked halfway, 66 samples: median 834, p95 1588.
Median 538 ms, 688 ms, 834 ms; p95 881 ms, 1,292 ms, 1,588 ms, by load. (measured on willder.ai, Oct 29, 2026)

Deletion

A fact written by an agent was deleted from a signed-in session, then read by one agent per agent read path until it was gone. All 7 agent read paths stopped presenting it within the 30-second limit; the signed-in web routes were not part of this measure.

When each of the seven agent read paths stopped presenting a deleted fact. Horizontal bars, milliseconds after the delete until the fact was absent, one bar per agent read path, all within the 30-second limit; signed-in web routes were not measured. memory_query 0, memory_entity 557, memory_version 900, memory_changes 1049, change notice 1274, GET changes 1870, task-start brief 2311.When each of the seven agent read paths stopped presenting a deleted fact. Horizontal bars, milliseconds after the delete until the fact was absent, one bar per agent read path, all within the 30-second limit; signed-in web routes were not measured. memory_query 0, memory_entity 557, memory_version 900, memory_changes 1049, change notice 1274, GET changes 1870, task-start brief 2311.When each of the seven agent read paths stopped presenting a deleted fact. Horizontal bars, milliseconds after the delete until the fact was absent, one bar per agent read path, all within the 30-second limit; signed-in web routes were not measured. memory_query 0, memory_entity 557, memory_version 900, memory_changes 1049, change notice 1274, GET changes 1870, task-start brief 2311.When each of the seven agent read paths stopped presenting a deleted fact. Horizontal bars, milliseconds after the delete until the fact was absent, one bar per agent read path, all within the 30-second limit; signed-in web routes were not measured. memory_query 0, memory_entity 557, memory_version 900, memory_changes 1049, change notice 1274, GET changes 1870, task-start brief 2311.
All 7 agent read paths, the slowest (Task-start brief) after 2,311 ms. (measured on willder.ai, Oct 29, 2026)

Working notes under load: a miss

A working note or claim should reach another agent's next tool result within 2,000 ms at p95, with six agents posting at once for three rounds. The two earlier runs passed; the latest did not.

How long a working note took to reach another agent, six agents at once, p95. Horizontal bars, milliseconds, p95 delivery of working notes and claims to another agent's next tool result with six agents posting at once, 90 samples per run, with the 2,000 ms bar marked. 28 Oct: 1977. 29 Oct, early: 1849. 29 Oct, late: 2079, over the bar.How long a working note took to reach another agent, six agents at once, p95. Horizontal bars, milliseconds, p95 delivery of working notes and claims to another agent's next tool result with six agents posting at once, 90 samples per run, with the 2,000 ms bar marked. 28 Oct: 1977. 29 Oct, early: 1849. 29 Oct, late: 2079, over the bar.How long a working note took to reach another agent, six agents at once, p95. Horizontal bars, milliseconds, p95 delivery of working notes and claims to another agent's next tool result with six agents posting at once, 90 samples per run, with the 2,000 ms bar marked. 28 Oct: 1977. 29 Oct, early: 1849. 29 Oct, late: 2079, over the bar.How long a working note took to reach another agent, six agents at once, p95. Horizontal bars, milliseconds, p95 delivery of working notes and claims to another agent's next tool result with six agents posting at once, 90 samples per run, with the 2,000 ms bar marked. 28 Oct: 1977. 29 Oct, early: 1849. 29 Oct, late: 2079, over the bar.
p95 1,977 ms, 1,849 ms, 2,079 ms over three runs; the latest, 2,079 ms, is over the 2,000 ms bar. (measured on willder.ai, Oct 28 and 29, 2026)
willder: system overview

  Browser / Next.js App Router (RSC + Client Components)
  │
  └── /api/*
        ├── auth gate    Clerk middleware  →  proxy.ts  (every protected route)
        ├── memory/*     check() → GuardedMemoryStore → HybridMemoryStore
        │                     ├── GraphitiMemoryStore   (Neo4j, temporal facts)
        │                     └── PgvectorMemoryStore   (Neon pgvector, MemoryChunk)
        ├── memory/upload   binary file → Vercel Blob → Inngest fileIngest → text → memoryIngest
        ├── sources/*     connect a connector → sourceSync (checksum-gated re-ingest)
        ├── agents/*      Handoff, Architecture and QA workflows
        ├── briefs/*      view · correct (→ dispute / auto-merge) · persist to memory
        ├── facts/disputes  scope-admin resolution, provenance-ranked
        └── access/*      grants and capabilities: mint, revoke, audit

  Inngest background jobs
  ├── memoryIngest   resolveEntities() → distillFacts() → Graphiti + pgvector
  ├── fileIngest     Vercel Blob file → extracted text chunks → memoryIngest
  └── sourceSync     poll a connector → SourceFile checksum diff → memoryIngest

  Hosted MCP  (/api/mcp, Streamable HTTP)
  └── Compatible client → bearer capability → scoped tool execution
        13 tools, each listed under Distribution below
        task-start context and subsequent change notices when available

  Memory service  (Python / FastAPI  :8000)
  ├── /ingest   →  Graphiti.add_episode  (LLM extraction + Neo4j write)
  ├── /search   →  Graphiti.search  (BGE-384 embed → cosine + BM25 → RRF fusion)
  └── /embed    →  BGE-small-en-v1.5  (384-dim, shared with pgvector)

  Access control  (services/access/)
  └── check(ctx, { action, scope })
        ├── human  →  grants JOIN scopes ON path @> target_path  (ltree containment)
        └── agent  →  capabilities WHERE tokenHash = SHA256(token)
                        AND revokedAt IS NULL AND expiresAt > now() AT TIME ZONE 'UTC'
Stack at a glance

No magic. Just good choices.

Every layer has a reason. Nothing here is incidental.

FrontendNext.js 16 App Router, React Server Components, Tailwind v4, HeroUI
AuthClerk sessions and organizations; Willder scoped capability tokens
Relational storeNeon (Postgres, pgvector, ltree), Prisma ORM
Graph storeNeo4j AuraDB via Graphiti (Python)
File storeVercel Blob: a private copy of each uploaded file, kept until the upload is removed, and of each synced Drive file, deleted once extracted; not a search backend
Memory serviceFastAPI + BGE-384 embeddings
Background jobsInngest (durable fan-out, retries, concurrency caps)
Agent modelConfigurable models for Handoff, Architecture and QA workflows
Bring your own AIHosted MCP over Streamable HTTP; compatible clients connect with scoped bearer tokens
TracingLangfuse
Architectural decisions

The hard calls. Every tradeoff documented.

These are the decisions where a reasonable team would choose differently. We chose these for specific reasons and we're living with specific costs.

01

Hybrid stores in parallel, not one

Decision

General memory retrieval is hybrid: every read queries all the stores the account tier allows (Graphiti, and pgvector's vector and text legs) in parallel, with bounded latency, deduplicates by normalized text and fuses the text leg's ranking by reciprocal rank (k = 60).

Alternative considered

Pick one. Graphiti alone for everything, or pgvector alone.

Cost we're living with

Deduplication layer, two failure modes, two ingest paths per write.

Why

Graphiti extracts clean subject-verb-object facts from short content but degrades on long documents. pgvector retrieves semantically similar chunks but has no concept of entities or relationships. Neither alone handles both cases well. The hybrid combines structured graph facts with semantic document chunks; retrieval can still miss relevant information.

02

ltree scope tree, not flat RBAC

Decision

Postgres ltree extension: scopes form a tree, a grant on any ancestor covers all descendants.

Alternative considered

Role-based: admin | member | viewer. Three levels, two DB rows.

Cost we're living with

ltree is obscure. Every memory check fires a SQL query with a @> operator. Grant lookup plus an audit-log write adds database work to each check. Complexity lives in the access layer.

Why

Flat roles can't express "EP-1 editor sees EP-1 but not EP-2, and Story Editor sees EP-3 through EP-5." Real enterprise access is hierarchical. ltree containment (@>) handles arbitrary depth in a single index scan.

03

Scoped tokens for agents

Decision

Short-lived, scoped, hashed tokens. The raw token is shown once. Only SHA-256(token) is stored.

Alternative considered

JWTs issued via OAuth flow, or just pass the user's session to the agent.

Cost we're living with

Custom auth system. No ecosystem SDK. Every agent call validates the token against the DB.

Why

Agents need machine-to-machine auth that works without a user present. JWTs can't be revoked without a blocklist. Session cookies leak the user's full permissions. A scoped capability token expires, is revocable, and is limited to one permission on one scope tree path.

04

Pre-processing entity resolution, not post-processing

Decision

Normalize entity references before Graphiti sees the text. Aliases, org roles, and CRM names are substituted upstream.

Alternative considered

Let Graphiti extract entities as-is, then merge duplicate nodes after the fact.

Cost we're living with

Every human write goes through the resolver. LLM coreference pass for pronoun references adds latency.

Why

Once Graphiti creates "Tri" as a separate node from "Tri Nguyen", fixing it requires an edge transfer operation that updates every connected relationship. Pre-processing prevents fragmentation for ~0 extra cost. Post-processing graph repair is significantly more expensive and error-prone.

05

Python microservice for memory, not Node.js

Decision

Separate FastAPI service owns Graphiti, Neo4j and BGE-384 embeddings.

Alternative considered

Node.js-only stack. Use OpenAI embeddings via API instead of local BGE.

Cost we're living with

Polyglot stack. Two services to deploy. Cross-process HTTP on every ingest and query. Shared API key between services.

Why

Graphiti is Python-only. BGE-384 runs in PyTorch, and there's no comparable Node.js solution for local embeddings at this quality. OpenAI text-embedding-3-small is $0.02/1M tokens but adds latency, a vendor dependency, and breaks offline testing.

06

Single check() chokepoint, not distributed checks

Decision

Guarded memory operations use check() for default-deny authorization and an audit record of the decision. Invalid credentials can be rejected earlier.

Alternative considered

Inline permission checks at each route. Faster, fewer DB queries.

Cost we're living with

Permission lookups and audit writes add database work to memory operations. Scope expansion and individual result checks can add more queries.

Why

Distributed access checks drift. When the same logic lives in 6 routes, one will be missing a case. A shared permission check gives the implementation one policy boundary to test; it is not a guarantee that every external request reaches that check.

Memory stores

Four memory stores, four jobs.

General memory retrieval is hybrid: every read queries all the graph and vector stores the account tier allows, in parallel, and fuses the results; the vector search also matches the words of a query that quotes stored text. Postgres also holds structured conventions and task/session records; Blob retains original uploads for ingestion. These are four storage roles, not an automatic router across four search engines. Keyword and episodic retrieval are proposed capabilities, not additional shipped stores.

Postgres (Neon, relational)

  • Identity, orgs, scopes, grants, capabilities
  • audit_log: decisions recorded by permission checks
  • Conventions, decisions and task/session state
  • Identity, access policy and typed application records

pgvector (Neon)

  • MemoryChunk: raw semantic chunks for long-form content
  • 384-dim BGE-small-en-v1.5 embeddings
  • IVFFlat cosine index
  • Semantic retrieval when selected by query and tier

Graphiti (Neo4j)

  • Temporal facts and relationships; removal is a separate operation
  • Entity extraction via LLM
  • Group-id partitioned per scope path
  • Graph retrieval; may run with vector search

Blob (Vercel)

  • Raw uploaded files (PDF, DOCX, images)
  • Original files retained as source material
  • Supported content is extracted into the ingest pipeline
  • Not selected by the general graph/vector search router
Access control

Agent access control: scoped tokens, default deny.

Guarded memory operations call the shared permission check. A human has a session and grants; an agent has a capability token. The check records its decision with the actor and scope. A missing or unknown MCP token gets HTTP 401 before any tool runs, unrecorded, since there is no org to record it against; an expired or revoked token gets the same 401 and a capability.refused audit row.

One memory read. An agent's memory_query arrives with its token. check() decides it against grants, the token's scope, expiry and revocation, and writes one audit row either way. A denied read stops there. An allowed read asks every store the tier allows, in parallel and only in the authorized partitions; the results are fused, any replaced version is swapped for its current successor, and the reply names each fact's writer, date and version.

One memory read. check() decides it and writes one audit row either way; a denied read searches nothing, and an allowed one asks the stores only in the partitions the check allowed.

check(): the single chokepoint

// Simplified flow; the service owns the full policy.
check(context, { action, scope, target })

Human:  resolve the actor's grants and scope ancestry.
Agent:  verify the token, actor, permission, scope and lifecycle.
Result: allow or deny; record the permission decision in audit_log.

// Hosted MCP first rejects missing or invalid bearer credentials.
// Those HTTP 401 responses may happen before check() and its audit write.

Actions

memory.read / memory.write / memory.delete / access.manage

Scope tree

org_<id> → dept → account → deal (Postgres ltree)

Token TTL

Default 7 days, max 90 days; an admin token at most 2 hours. Hash = SHA-256. Raw never stored.

Scopes form a tree. The organization acme has two projects, acme.atlas and acme.borealis. acme.atlas has two children, acme.atlas.research and acme.atlas.deploy. A token minted on acme.atlas reads acme.atlas, acme.atlas.research and acme.atlas.deploy. It never reads acme.borealis, a sibling, or acme, its parent.

Scopes form a tree of Postgres ltree paths. A token on acme.atlas covers it and everything under it (granted_path @> target_path), never a sibling or a parent.

Revoking a capability stops future requests using that token. Source removal and fact deletion are separate operations; neither revocation nor deletion retracts an answer already returned to another system. Review grants and agent tokens when closing access paths.

Only a person can issue a token, of any permission, so an agent cannot mint or extend its own; a token with admin permission expires within 2 hours. An agent that read content from outside the scope it writes to, such as a sibling or a parent, is refused unless it declares the share; a declared share is recorded and readers of the scope it came from are told.

Hybrid retrieval

Every allowed store, in parallel, then fused.

After the check, the graph and the vector store are asked at the same time, each inside the account tier's latency ceiling (4 seconds on team, 10 on enterprise, per store call) and asked once more if it fails. The graph ranks facts by BM25 and embedding similarity, fused by reciprocal rank in the memory service. When a query quotes a line of four or more words, the vector store first runs a trigram text match, then its embedding search. The starter tier allows neither store today, so its read asks none.

One hybrid read. After check() allows it, the graph and the vector store are asked in parallel, each inside the account tier's latency ceiling and asked once more if it fails. The graph ranks facts by BM25 and embedding similarity, fused by reciprocal rank inside the memory service. The vector store first runs a trigram text match when the query quotes a line of four or more words, then its embedding search with the rows the text match found among the candidates, so the text match can bring in a row the embedding search missed. Graph facts and vector chunks are deduplicated by their normalized text, the text match's ranking is fused in by reciprocal rank fusion with k = 60, replaced versions are swapped for their current ones, and a store that still failed is reported as failed.

The vector store runs its text match first, then its embedding search with those rows among the candidates, so the text match can bring in a row the embedding search missed. Graph facts come first, then vector chunks; copies with the same normalized text are kept once. The text match's ranking is fused in by reciprocal rank fusion, 1 / (60 + rank) per ranking, inside the vector store and again after deduplication.

A replaced version that ranked is swapped for the version that replaced it, and each answer names its writer, date and version. A store that still fails is named as failed in the read's result, never left to look like a store that had nothing to say. Retrieval can still miss relevant information.

Versions and conflicts

A write from a stale read is refused.

Every write of a fact is a version. A write that names the version it read with baseVersion is refused with stale_version: when someone replaced that version in between, and nothing is stored. Clients that never send a base are covered too: the server keeps its own record of every version it served to each agent.

A write based on a stale read is refused, and nothing is stored. Agents A and B both read version v1 of a fact. B writes with baseVersion v1 and its write is stored as v2. A then writes with baseVersion v1; Willder refuses with stale_version naming v2 and stores nothing. A reads v2 with memory_version, then writes with baseVersion v2, which is stored as v3.

Agent A writes back from a read that B has since replaced. The write is refused naming v2; A reads v2 and writes again.

Two writes of the same fact within one second, neither writer having seen the other, are a conflict: the version stored first stays current, the other is kept as superseded and stays readable, and its writer is told on its next tool call. Willder detects and reports the conflict; it does not merge the versions.

Ingest

Sources become scoped, deduplicated memory.

Connectors sync into a scope, files sync into idempotency, and names get normalized before the graph ever sees them.

Connectors

GitHub, Google Drive, Slack, Figma, PostHog. Disconnecting a source stops its content answering; measured on GitHub.

Who can read it

A source takes the access of the scope it lands in. Connecting it more widely than it was shared needs a confirmation, which is recorded.

Idempotency

SourceFile records a checksum per file; a re-sync skips anything unchanged.

memoryIngest: every write, from any source

1. resolveEntities()    EntityAlias registry → org roles → LLM coreference
                         normalizes names/pronouns to one canonical entity BEFORE the graph sees them
2. distillFacts()       LLM extracts 3-10 SVO sentences  (if content > 300 chars)
3. Graphiti.add_episode() → entity extraction → RELATES_TO edges + temporal invalidation
4. pgvector INSERT      embedding via /embed endpoint (shared BGE-384 model)
Distribution

An MCP memory server first. The web UI is a client of the same gate.

Connect a client that supports Streamable HTTP and a custom Authorization header. Issue a scoped token from Access → Tokens; Integrations → Connect your AI shows connection guidance. Tool execution uses the shared check() permission boundary. An OAuth-only client cannot use this bearer-token flow directly.

Hosted MCP: no source checkout or database credentials

Endpoint: https://willder.ai/api/mcp
Transport: Streamable HTTP
Authorization: Bearer <your scoped token>

initialize → tools/list → tools/call
memory_query        read
memory_entity       read
memory_version      read
memory_changes      read
memory_note         write
memory_ingest       write
fact_link_source    write
get_my_brief        read
memory_stale        read
contact_search      read
contact_read        read
contact_upsert      write
send_feedback       read

Inspect result.isError as well as HTTP status.
Never place a real token in source control or a prompt.
Agents

Three workflows. Shared governed context.

Handoff, Architecture and QA are available from Agents. Choose the workflow, provide its required inputs and inspect the output. Availability does not guarantee that a run succeeds or that a generated answer is correct.

Available workflows

Handoff → scoped handover brief
Architecture      → plan based on selected memory and task context
QA                → checks against a supplied target and available specifications

Each workflow reports its run state and output for review.
External agents can use the same governed memory through hosted MCP.

Follow the quickstart to share one scope, read through that connection and revoke its token. Verify the response and recorded permission decisions. Previously delivered context remains with its recipient.