# Helix Foundry: agent guide
The Helix Foundry site, in two builds: the hosted site, foundry.helix-db.com (branch `main`) and dev.foundry.helix-db.com (branch `dev`) on Vercel, and the local build that `local/` runs on one machine. A Next.js 16 app that serves three things:
- the Foundry UI: a single-page app (react-router 7, TanStack Query), mounted client-only;
- sign-in: helix-website's WFE-brokered WorkOS flow, run as the Foundry AuthKit application;
- `/papi`: a same-origin BFF that forwards an explicit allowlist of WFE routes with the session's bearer token.
Foundry's API is `backend/` (Backend, below): `helix.foundry.v1`, every route under `/v1/foundry` and `/v1/workspaces/{workspaceId}/foundry`. Hosted, the Helix WFE serves those routes beside its own, which the site also calls (whoami, workspaces, members, projects, tenants, billing). Their REST contract is vendored in two parts, Foundry's in `contract/foundry.swagger.json` and the WFE's own in `contract/wfe.swagger.json`, with the ontology agent's Read and Ask JSON Schemas, whose JSON Foundry carries as strings, in `contract/ontology_agent/` with their `VERSION`. `pnpm contract:sync` copies Foundry's contract and the schemas from `backend/`, and the WFE's from `WFE_REPO`, a checkout of hosted Helix's backend, which only Helix's maintainers have.
Next.js 16 differs from older versions. Read the matching guide in `node_modules/next/dist/docs/` before using an API you have not used here.
## Commands
| Command | Does |
| --------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `pnpm install` | Node 24, pnpm (version in `packageManager`) |
| `pnpm dev:mock` | The app on http://localhost:3000 against the mock WFE on :4010. No accounts or secrets. Sign in with any provider; `ada@example.com` already has the Acme workspace, any other email starts with none. |
| `pnpm dev:mock:local` | A local build (below) on http://localhost:3000 against the mock on :4010 playing its API: no sign-in, Ada owns Acme, every connector is open. |
| `pnpm dev` | https://local.foundry.helix-db.com:3000 against the WFE in `.env.local` (copy `.env.example`). Needs mkcert and `127.0.0.1 local.foundry.helix-db.com` in `/etc/hosts`. |
| `pnpm typecheck` | `tsc --noEmit` |
| `pnpm lint` | ESLint, then `prettier --check` (`pnpm format` fixes formatting) |
| `pnpm test` | Vitest unit tests in `tests/unit` |
| `pnpm test:coverage` | The unit tests with coverage of `src/server`, `src/shared`, the browser API client and the areas' logic (`model.ts`, `plan.ts`) |
| `pnpm e2e` | Playwright in `tests/e2e`: a production build in `.next-e2e` against the mock, on ports 3100 and 4110, and a local build in `.next-e2e-local` against its own mock (`tests/e2e/local`), on 3101 and 4111. Needs Chromium (`pnpm exec playwright install chromium`). |
| `pnpm e2e:stack` | `tests/stack/run.sh`: the real local stack (`local/`), fresh, with a sample PostgreSQL database, driven in Playwright's image. Runs the site and foundry built from source (`helix-foundry/{site,foundry}:local`; it builds foundry's) and the release's ontology agent, or the images `FOUNDRY_*IMAGE` name. It starts as a fresh clone does, with nothing set up (the workspace the install makes, its secrets written into the stack's volume), and with no AI provider (`FOUNDRY_AI_PROVIDER=none`, so nothing on the machine is found instead), so uploads and the draft wait for one; `FOUNDRY_STACK_MODELS=1` makes Claude the install's provider with the shell's `ANTHROPIC_API_KEY`, uploads, waits for the draft, then loads it and asks. |
| `pnpm docs:media` | The docs' screenshots and videos (`tests/docs`) into `docs-media/`. `pnpm docs:media:upload` publishes them to assets.helix-db.com, which docs.helix-db.com links. |
Run all four checks (`typecheck`, `lint`, `test`, `e2e`) before you finish. CI (`.github/workflows/ci.yml`) runs the same.
## Layout
| Path | Contents |
| ------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
| `src/app` | Next routes only: `auth/login`, `auth/logout`, `login/callback`, `papi/[...path]`, and `[[...slug]]`, which mounts the SPA client-only |
| `src/server` | Server-only code: auth config, cookies, sessions and single-flight refresh, CSRF, the BFF handler and **the allowlist** (`papi-allowlist.ts`) |
| `src/shared` | Used by both sides: `wfe.ts` (every WFE payload type), route templates, return paths, the CSRF contract |
| `src/web` | The SPA: `main.tsx`, `api.ts`, `paths.ts`, `ui.tsx`, `kit/`, `shell/`, `areas/ /`, the sign-in pages and the global CSS |
| `mock` | The mock WFE (`server.ts`), its state, tokens, the WorkOS stand-in and `routes/` |
| `contract` | The vendored WFE swagger and the commit it came from; `ontology_agent/`, the agent's Read and Ask schemas |
| `tests/unit`, `tests/e2e` | Vitest and Playwright |
| `tests/docs` | The docs' captures: one spec per docs section, `support.ts` for screenshots, the recorded pointer and captions |
## Rules
- **The allowlist.** The browser reaches the WFE only through routes listed in `src/server/papi-allowlist.ts`: one entry per method and path, written exactly as in the swagger, with `{param}` matching one path segment. No wildcards, no prefixes. Add only routes the UI calls. Never add sign-in, SSO management, role changes or member removal. `tests/unit/papi-allowlist.test.ts` fails unless every entry is in the contract and served by the mock.
- **WFE types** live in `src/shared/wfe.ts` only, written from the swagger, under a heading per area. grpc-gateway JSON is camelCase, enums are names, int64 are strings, and default values may be missing.
- **Agent JSON** (Read results, Ask events and results) is the engine's, typed from `contract/ontology_agent/` in `src/shared/foundry-read.ts` and `wfe.ts` "Ontology: questions", and parsed without trust. The mock emits exactly those shapes, and `tests/unit/agent-contract.test.ts` validates every mock answer, every fixture and the reads the SPA sends against the schemas (`mock/agent-contract.ts`, ajv), so a contract change fails CI.
- **Calls** go through `api()` in `src/web/api.ts` with WFE paths (`api("/v1/whoami")`). It adds the CSRF header on unsafe methods and sends a signed-out user to sign-in.
- **Look.** Everything uses the kit, shell, tokens and copy tone (`src/web/kit`, `src/web/shell`, `src/web/workspace.css`). Build a new screen from the kit and an existing area's CSS rather than inventing styles, with colors as tokens. Do not edit the kit to suit one screen.
- **Colors** are `--hf-*` tokens in `src/web/workspace.css` only: the light set on `:root`, the dark set in two identical blocks (`@media (prefers-color-scheme: dark)` under `:root:not([data-theme="light"])`, and `:root[data-theme="dark"]`). A new color gets a value in all three, or is listed in `SAME_IN_BOTH`. No other CSS has a color literal; `tests/unit/theme-css.test.ts` checks that, and the dark theme's contrast (4.5:1 text, 3:1 icons and marks).
- **Cookies** are Foundry's own and host-only: `__Host-foundry-session` (`WORKOS_COOKIE_NAME`), `__Host-foundry-csrf`, `__Host-foundry-login-`. On loopback http (mock, e2e) they drop the `__Host-` prefix. Never set a cookie domain.
- **Secrets** never go in the repo. `.env.example` names the variables. The values in `mock/env.ts` are mock-only fixtures.
## Adding an area
An area (Workspace, Data, Ontology) owns its folder (step 1) and adds to four shared files, each in a section of its own (steps 2 to 5):
1. `src/web/areas/ /`: `index.tsx` (route components; Data and Ontology export a `*Routes` table for their URL prefix, and Ontology also `ExploreRoutes` and `AskRoutes` for `/explore` and `/ask`), `paths.ts` (URL helpers, merged into `src/web/paths.ts`), `shell.ts` (sidebar children, a section's panel and command-palette sources), and its CSS.
2. `src/web/main.tsx`: the area's routes, if it adds a new prefix.
3. `src/server/papi-allowlist.ts`: one line per WFE route the area calls, in the area's section.
4. `src/shared/wfe.ts`: the payload types, under the area's heading.
5. `mock/routes/ .ts`: the same routes with in-memory state, spread into `mock/routes/index.ts`. Use `membership()` from `mock/state.ts` for workspace-scoped routes and `onReset()` for the area's state.
Then add the area's main flow to `tests/e2e/` and its logic to `tests/unit/`.
## Things to know
- Names and Records: wherever an entity shows (the Explorer's canvas, tooltips, inspector and search, Records, an entity's page, a document's view) it reads by the version of the load the page reads (`explore/naming.ts`, `useNaming(versionId)`; Other data reads by the current load's version): the first non-empty `names` prop of its type, else its key props joined, else its stored `name`, else a short id; a label the version doesn't know, or no current version, keeps the raw label and the explorer's own caption. `__nn` (normalized names), an entity's `entity_key` and `norm_name`, a fact's `fact_key`, anything's `foundry_load` and Foundry's other stamps (`foundry_*`, such as `foundry_tx`) on all but Foundry's own labels never show in property lists; those stamps (`isFoundryStamp`) also never match the Explorer's search or take its `contains` scan's keys. Records (`/explore/{id}/records`, `areas/records`) is the Explorer's table view: its types are listed in the Explorer's panel (above the table while the panel isn't on screen); every query in `records/model.ts`, each bounded (a page is `range` over one type, one row more than a page, at most 10,000 rows deep; searches and filters count up to 1,000).
- **Running Foundry locally**: `local/` (compose, README, `.env.example` of optional overrides) runs the local build, with nothing to set up: `docker compose up -d --wait` from the root (its `compose.yaml` includes `local/compose.yaml`: one project, `helix-foundry`, the same volumes from either place, and Compose reads `local/.env` for that file from either) or from `local/`. Its first service, `init-secrets` (`foundry init-secrets` in the foundry image, offline), writes the secrets the `secrets` volume lacks, once: an old install's `FOUNDRY_*` from its `local/.env` (setup.sh's, before 0.1.1), else random ones; every other service waits for it and reads its own from the volume (the site `FOUNDRY_LOCAL_TOKEN_FILE`, MySQL `MYSQL_PASSWORD_FILE`, the store its file, foundry `local.secrets_dir`). The connector key there seals every stored credential and is never made again. foundry makes the owner's first workspace, My workspace, until the install has had one, and with no `FOUNDRY_AI_PROVIDER` finds its AI default on the machine (`backend/ai/detect`: the shell's `ANTHROPIC_API_KEY`, then `OPENAI_API_KEY`, then an Ollama with a model that calls tools, then LM Studio; `none` is none). It runs the standalone `foundry` image (Foundry's backend in one process: its API, a directory of the one owner's workspaces, projects and databases, and its workers; built from `backend/`), the ontology agent, SeaweedFS, MySQL and the public helix-db image. That HelixDB keeps no tenants apart: every ontology's loads share one database, so one ontology's Other data shows labels another's loads wrote. The site's `Dockerfile` builds its image.
- **Local build** (`NEXT_PUBLIC_FOUNDRY_MODE=local`, fixed at `next build`; `src/shared/deployment.ts`): a copy someone runs on their own machine. It has one owner and no sign-in, members, billing or sign-out. `/papi` (`src/server/local.ts`) answers only the Host of `FOUNDRY_ORIGIN`, which must be a loopback origin, and forwards as the owner with `FOUNDRY_LOCAL_TOKEN`, else the token in the file `FOUNDRY_LOCAL_TOKEN_FILE` names (read once it reads; shared with the API, 32+ bytes; the CSRF secret is derived from it) under the same allowlist, origin and CSRF checks. The API refusing the token is shown as a setup error, never a sign-in. Anything the hosted site shows about members, the plan or the Helix dashboard is behind `LOCAL`, as are People enrichment's settings (locally the API reads the setting as unavailable, so Edit offers no Enrich people). A local build offers no API source (`connectorKindsQuery` drops the `events` kind, and the palette has no New API source): their senders reach Foundry through the Helix MCP, which a local install doesn't run, and the local API refuses their send tokens.
- Refresh: Foundry access tokens live 300 s and WorkOS refresh tokens are single use. AuthKit refreshes 30 s before expiry, and a failed refresh while the token is still valid keeps the session. Once the token has run out, a second refresh of the same cookie fails and ends the session, so: every `/papi` answer says how long the token has left (`x-foundry-session-expires-in`, `src/shared/session-contract.ts`), and once that is under 10 s, or not known yet, `api.ts` sends its calls one at a time under a Web Lock shared by the origin's tabs until one reports a fresh token. That holds across server instances. On one instance, parallel calls also share one refresh per session cookie (`src/server/single-flight.ts`), and a rotated session is reused for 30 s by requests still carrying the old cookie.
- Onboarding: a workspace with no ontology shows nothing but the new-ontology wizard (`main.tsx`'s `WorkspaceRoutes`, `onboarding` on `useWorkspace()`). The sidebar keeps only the workspace switcher and the account menu (no nav, no search, no palette). Every other path redirects to `/ontology/new`, except an ontology's own pages (the one just created opens before the list has it) and `/settings/*` (the plan a database connection needs). The Helix MCP's connector links (`/data?add=`) open their form over the wizard. To test the full app in the mock, sign in as `ada@example.com`; a new email lands in onboarding.
- Workspace switching is client-side (`src/web/workspaces.ts`): a Foundry session works on every workspace the user belongs to. When sessions become workspace-scoped, the token swap goes in `chooseWorkspace`.
- Links into the app may name their workspace with `?workspace=`: `SignedIn` opens and remembers it when the user belongs to it, says so when not, and drops the parameter. `/data?add=` opens that kind's form in the Add data modal (`?add=1` the picker). The Helix MCP's `helix_foundry_connector_link` sends users to `/data?add=&workspace=`, so connector credentials are entered here, never through an agent.
- Sign-out returns to `/login`. WorkOS must list `https:///login/callback` as a redirect URI and `https:///login` as a logout redirect for the Foundry application.
- Password, SSO and email-verification sign-in are not offered: the callback sends those cases to `/login?error=sso|verify`.
- Foundry Pro and Max (`src/web/areas/workspace/plan.ts`, which holds the prices: Pro $100 and Max $500 a month; the WFE returns none): one Autumn group, so a workspace holds Free, Pro or Max, and Max grants everything Pro grants. Autumn is the system of record and the WFE reads it live, so the plan query is cached for a minute. Every stamp's plans offer every connector kind (paid plans sync more often); the WFE refuses a kind a plan leaves out (create, test, update and sync) with FAILED_PRECONDITION and reason `FOUNDRY_PRO_REQUIRED`, which the mock keeps for PostgreSQL, MySQL and S3 on Free. The Data area renders ` ` (`areas/workspace/pro-gate.tsx`) for a kind with `available: false` (the picker, and a Pro connection's page after the workspace left Pro) and in place of a form error for which `isProRequired(error)` is true. It links owners and admins to `/settings/billing`, and the billing page refreshes the connector kinds once the plan bought shows. A Max workspace passes every Pro gate, so it never sees one. The plan's tier shows on Plan & billing and the account menu.
- Billing: checkout (`CreateFoundryProCheckout`) sends a new `clientToken` per click and always the `tier` (`FOUNDRY_TIER_PRO` or `FOUNDRY_TIER_MAX`; unset is Pro). From Free, the upgrade dialog picks Pro or Max and takes a promotion code; a paid workspace switches to the other tier from its card, behind a confirm dialog, by checking that tier out (asking for the tier held attaches nothing). Cancel and resume act on whichever tier is held. After an answer that may have charged (`checkoutFailure` is `unsure`) the page reads the plan before offering checkout again, and Autumn refuses a second attach of a held plan. Stripe Checkout returns to `/settings/billing` (the WFE's `billing.autumn.foundry.return_url`), and that page keeps reading the plan for a while after a checkout this tab started, until the tier it bought shows. The mock's checkout and billing portal are `/mock/checkout` and `/mock/billing-portal`; `POST /__mock/config {"checkoutCardOnFile": true}` charges without a page.
- `/` is Ask (`main.tsx` redirects it to `/ask`, keeping the link's query), on the ontology last opened, else the newest; a workspace without an ontology goes to the wizard. Ask is the sidebar's first section and is current on `/`, with its threads under the sections. There is no Home page.
- Mock users: `alan@example.com` is a plain member of Acme, for what non-managers see; `POST /__mock/config {"alanRole": "WORKSPACE_ROLE_ADMIN"}` makes him an admin until the next reset, for what admins (not owners) see. Any other new email starts with no workspace, named from the email unless the mock sign-in page's Name is filled in.
- Members are read-only here and invitations are create-only. Role changes, removal and pending-invitation management stay in the Helix dashboard (`NEXT_PUBLIC_HELIX_DASHBOARD_URL`, set per stamp in `next.config.ts`), where invitations also land.
- The PostgreSQL and MySQL guides list the stamp's outbound addresses from `NEXT_PUBLIC_HELIX_EGRESS_IPS` (set per Vercel environment: the stamp's NAT egress IPs); without it they only say to allow Helix's outbound address.
- API sources over HTTP: `NEXT_PUBLIC_HELIX_EVENTS_URL` (per stamp in `next.config.ts`: `https://ingest.helix-db.com` on prod, `https://ingest.dev.helix-db.com` on dev and in the mock, empty in local mode) is where Foundry's `foundry-events` role takes `POST /v1/sources/{id}/pieces` with a send token as the bearer. Empty, every mention of HTTP is hidden: the send panel's "Send over HTTP" (`eventSourceCurl()`, which reads the token from `HELIX_EVENT_TOKEN`, and `eventSourcePiecesExample()`), the prompts' HTTP section and `send_url` line, and the "app or agent" copy of the picker, palette, form, page and token. The mock doesn't serve the endpoint, and the site never calls it.
- The mock has one Foundry plan per workspace, in `mock/routes/billing.ts`: Acme starts on Pro, a new workspace on Free, and checkout moves it to the tier asked for. A switch between Pro and Max charges the card on file at once (no checkout page) and reads the old tier once, as a card-on-file purchase reads Free once. The Data routes' Pro gate reads it (`isFoundryPro`, true on Pro and Max); unit tests set it with `setFoundryPlan`. To test an upgrade end to end, start from a new workspace (`signInToNewWorkspace` in `tests/e2e/support/mock.ts`); a switch starts from Acme.
- Amazon S3 (`areas/data/connection-s3.tsx`, a Pro kind): files in one folder of a bucket become tables (the WFE lays keys out into tables; the user picks tables by name, `tables` empty meaning every one). Access is an IAM role or an access key. A role is offered only when the kind's `s3` setup (`principalArn`, the workspace's `externalId`) is in the connector kinds (an edit keeps a stored role, which without the setup can be renamed but not tested); the role panel shows the external ID and the trust and permissions policies to paste, written by `s3TrustPolicy` and `s3PermissionsPolicy` in `model.ts` exactly as the backend's S3 plan has them. A key's secret is write-only, and an edit that leaves it blank keeps the stored one; a role stores no secret. A test copies the region it detected into the form, and its tables carry `format`, `fileCount`, `bytes` (int64 strings, shown in binary units by `formatBytes`), up to 5 `preview` rows and a `problem` (the sync fails until it is fixed); Connect is disabled while the chosen tables' bytes are over the discovery's `maxBytes`. Changing the bucket clears an AWS region, which the next test detects again.
- Data (`areas/data`): every Data page sits beside the Data explorer (`explorer.tsx`), the workspace's data as a tree, as in an IDE: at its root a folder per connector kind that has a connection, the workspace's own folders of connections, and `documents/` (`documents-tree.tsx`) with a folder per document set holding its documents (`tree.tsx`). It is the Data section's panel beside the sidebar (`panel.data` in `shell.ts`, ``), outside the page's routes, so it reads only the URL and the query cache, and calls `onNavigate` on every link it follows (the phone drawer closes). It is a `role="tree"` (`useTreeItem` in `arrange.tsx`; `explorerRows`, `rowKeyAction`, `selectionOf` and `selectedRow` in `layout.ts`): one Tab stop (the row last focused, else the page's, else the first; only that row's buttons are in the Tab order), the arrow keys, Home and End move between rows and open and close folders, Enter opens a row and F2 renames it. The row of the page open (a connection's page or one of its tables', a document's) has `aria-current="page"`, and the folders holding it open once each time the URL changes. Closed folders are remembered per browser (`foundry.data.closed`: `kind:`, `folder:`, `documents`, `set:`); the filter opens what it finds and lists the tables it matched under their connection (a folder's or set's name keeps all it holds). A connection or document opens with a click (its link covers the row), a folder opens and closes. A connection's sync status is a dot, said in its row's name; what a row holds is its name's tooltip; Sync, and a set's Upload, Describe and Delete, show while the row is hovered or focused. Owners and admins rename a folder, a connection, a set or a document in place (`InlineName`, `inline-name.tsx`: helix-foundry's suggested-ontology rename, Enter or leaving the field saves, Escape cancels, Enter and Escape give the focus back to the row), sync a connection, and create, describe (a dialog: the agent reads the description) and delete an empty set. `/data` (`overview.tsx`) is an overview: the totals, the connections in a DataGrid (a row opens it), and the document sets with what they hold and which ontologies read them, with Add data (`?add=1`, `?add=`) and Upload documents. The generate form groups connectors by kind (`kindFolders()`, `setFolders()` in `model.ts`, not the layout): a kind with several nests them under one tri-state choice (`picked()`), a lone one shows as itself.
- The Data layout (`areas/data/layout.ts`, pure, in `test:coverage`): how the tree is arranged, shared by the workspace in one JSON object the WFE stores without reading (`GET/PUT .../foundry/data-layout`, at most 64 KiB of compact JSON, `{layout, revision}`). Version 1 is `{v: 1, names, folders: [{id, name}], order}` over the containers `root`, `kind:`, `folder:`, `documents` and `set:`: `order` lists children (root's and documents' by container key, others by id), unlisted ones follow in the default order (kinds in the WFE's order, then custom folders, then documents/; everything else as created), so new ones go last. A connector sits in the custom folder whose order lists it, else its kind's; custom folders are root-level and hold connectors only, up to 50, names unique among root folders. `names` renames a kind's folder or documents/. `parseLayout` reads `{}` as empty and any other `v` as unknown: the tree then shows its usual order with a note and nothing moves. Every change is a `LayoutChange` that `applyChange` makes on any layout and prunes ids the workspace no longer has. `use-tree.ts` shows changes at once and saves them one PUT at a time; on FAILED_PRECONDITION it rereads the layout and lists and makes the change again once, then undoes it with a toast. It never writes while the connector, set, document or layout list is loading or failed. Owners and admins drag a row by its grip (`arrange.tsx`, `@dnd-kit/core` and `sortable`; a copy follows the pointer) within its list, a connection into a custom folder (its head: last; a row: before or after it by the pointer's half) or back to its kind's; clicking the grip, or Enter on it, opens its menu: Move up, Move down, Move to… for a connection, another set for a document (the WFE's move). New folder is in the explorer's head; a custom folder's menu deletes it, sending its connections back to their kinds' folders. The filter turns grips off; members see the order only.
- Data in the mock (`mock/routes/data.ts`, `data-sources.ts`): Acme starts with a synced Stripe account and PostgreSQL database. A key containing "bad" is rejected, a Stripe `rk_` key cannot read payouts, balance transactions or disputes, a PostHog key containing "multi" sees two projects, a GitHub token containing "nopr" cannot read pull requests or reviews, a repository named `*/missing` is one the token can't read, a Cal.com key containing "solo" has no teams (teams and team_memberships are unavailable), one containing "member" gets the Test warning that it is not a team admin, a Granola key containing "personal" cannot read spaces, the password "wrong" fails, private hosts are refused, and a PostHog sync over 90 event days fails. Syncs run about 2.5 s. With `MOCK_DOCUMENT_FIXTURES` (`pnpm dev:mock`), Acme's General set holds a read CSV and JSON file. A new table file reads for 1.5 s (`{"documentReadMs": n}`), and one that isn't what its type says fails its read. Every S3 bucket holds the plan's fixture folder, laid out by a port of the backend's layout (`data-files.ts`): tables api, customers, events, orders and products in eu-west-2, with notes.xlsx and an archived file it can't read. An S3 role ARN containing "untrusted" can't be assumed and one containing "open" doesn't require the external ID; the buckets missing-bucket, denied-bucket and empty-bucket fail as their names say, and bad-records has a malformed events sample, so its syncs fail. `{"s3RoleAccess": false}` takes the role setup away. There is no S3 seed. Syncs run about 2.5 s. The Data layout (`mock/routes/data-layout.ts`) starts empty at revision 0; a stale revision is FAILED_PRECONDITION, and `{"dataLayoutConflicts": n}` lets another writer come first on the next n PUTs.
- API sources (connector kind `events`; the WFE, its protos and the MCP call them event sources): people and their agents send them pieces of data, JSON objects with an id, through the Helix MCP; the site makes them and their send tokens and reads their sends, and never sends pieces itself (no WFE route does). A source is made, edited (name and description; its fields are learned from pieces, keep their first JSON type and are never sent) and deleted through the connector routes with `config.events`, and has one synced table, `events`, keyed by piece id. Its send tokens (`.../connectors/{connectorId}/tokens`, `hx_ev_` and 64 hex digits, shown once at create, at most 10 live) and recent sends (`.../event-batches`, with `pendingPieces` and `capacity`) have routes of their own. A source may have a text field (`config.events.textField`, the top-level field holding each piece's text), locked as a string once chosen; an update sends `description` and `textField` only to change them (left out, each stays; `""` clears the text field), and a text-field change queues one full sync and is refused while a sync of the source is live. With a text field, its syncs keep its newest 500 pieces as Markdown documents of its own set (" / Pieces", `connector.stream` `pieces`, `connectorKind` `events`), with this month's spend by work no person started against its $5 allowance (`monthSystemUsdMicros`, `maxMonthSystemUsdMicros`), and whether it is paused (`paused`: it holds documents and its allowance, the workspace's for API sources, or the month's share for such work is spent, so syncs no person started write none). A generation reads one only through `eventSources`: mode `FOUNDRY_EVENT_SOURCE_MODE_STRUCTURED` once its table has rows, or `FOUNDRY_EVENT_SOURCE_MODE_EXTRACTION` once it has a text field and a piece in its set, which then counts as one of the 20 sets and is never named in `documentSetIds`; `connectorIds` never names one, and its empty default never includes one. In the mock (`mock/routes/event-sources.ts`, the rules in `mock/event-pieces.ts`): there is no seeded source. `POST /__mock/event-sources//pieces` with `{clientToken?, pieces: [{id, occurredAt?, data}]}` (`mock.sendPieces` in the e2e, `tests/e2e/event-sources.spec.ts`) stands in for an agent's send, with the backend's rules, messages and client-token replay (`tests/unit/__fixtures__/event-pieces.json` is the backend's rule table for sends and text fields, the events package's `testdata/pieces.json`, and `tests/unit/__fixtures__/event-render` its render goldens, `testdata/render`: copy them again when they change). A send queues a sync that merges every waiting send about 1 s later (`{"eventSyncMs": n}`), and `{"eventCaps": {"maxRows": n, "maxBytes": n}}` lowers the table caps to fill a source. A source with a text field gets its Pieces set at its first sync after it, and every sync makes the set the newest 500 rows by receipt (an identical resend moves up in place, a changed body is a new document, older ones are deleted), rendered as the backend's `events.Render` lays a piece out (`mock/event-pieces.ts` `render`). A piece document that arrives while a person's load or extract is writing the ontology's load never joins it: the extract that job's end owes, no one's, reads it. The mock spends nothing of a set's allowance: `{"eventSetAllowanceSpent": true}` spends all of it, which pauses a set that holds documents: syncs no person started write and trim none until it is false again, while a person's (a text field's change) still writes, and clearing the text field still empties it. While it is spent, a job no person started holds the set's documents back and ends `budget_exhausted` with the backend's message naming the source; a load a person starts reads them. The mock's tokens are fake: made by a generator seeded on reset, kept as their SHA-256 only, and accepted nowhere. In the Data area (`areas/data/event-source-*.tsx`): the picker's "From your agent" card ("From your app or agent" where the stamp takes HTTP) and `?add=events` (the palette's New API source) open its form, whose second step is its send panel (`EventSourceSend`: the tokens, a new one shown once, then the copy-prompt panel over `eventSourcePrompt()`, then "Send over HTTP" where the stamp has it); its page has no Sync, and polls its sends and itself every 3 s while it waits for pieces (`eventSourceWaiting`: no rows, or pieces pending; `useEventSends` reads the connectors again once a sync takes the last pending pieces); `?token=new` opens the send panel in `EventSourceSendDialog`; the tree keeps sources in "API sources". `eventSourceLine` is a source's state line wherever it shows.
- Ontologies (`src/web/areas/ontology`): a workspace holds ontologies, each bound to one Helix database (a GA tenant) of any of its projects, one ontology per database; generations, versions, the current version and loads belong to the ontology, under `/ontology/{ontologyId}` (documents are the workspace's, in Data) (`scope.tsx` loads it once; pages read it with `useOntology()`). The section's panel is the Ontologies panel (`ontologies-panel.tsx`, `panel.ontology` in `shell.ts`, ``): the workspace's ontologies from `ontologyQueries.list`, each row its name, its database (muted) and a status dot for its last load (`lastLoadBadge` in `parts.tsx`), the open one's row (`aria-current="true"`) holding its pages: Overview (which also marks its versions', loads', `/generate` and `/generations/{id}` pages) and Documents (with `counts.documents` once the ontology is read; marked on its documents pages, current on the list), its documents as a file system nested under it (below); the page open `aria-current="page"`, New ontology in the head for owners and admins, a filter by name or database from 8 ontologies, and loading, error with Retry and none-yet states (none yet offers New ontology in its note instead of the head). Under its Documents, on every page of it, its documents are a file system (`OntologyDocumentsTree` in `documents/explorer.tsx`): the sets it reads (their `readBy` names it) or holds documents of, as folders of the Data explorer's rows, CSS and keyboard (`FolderRow`, `useTreeItem`, `rowKeyAction`; read only, closed folders in `foundry.ontology.documents.closed`), from its documents list and the set list (`ontologySetFolders`). Each document has a dot for what the current load made of it (`documentDot`: orange needs review, red couldn't be read, green read, grey waiting or not read), said in its row's name too; a set counts its documents to review, when there are any; a document opens its page there, its row `aria-current="page"` and its folder opened. There is no list page: the database and its project, the last load and the creation date that the old table showed are in the overview's header (`meta`; the project from `projectsQuery`, one ListProjects), and the document count is its documents button. `/ontology` (the sidebar's Ontologies and the pages' "Ontologies" breadcrumbs), `/explore` and `/ask` open the ontology last opened in this browser (`foundry.ontology.last.v1:`, written by the scope) while it exists, else the list's first (`OpenOntology`, a `replace` so Back skips it), else say there is none (`NoOntologies`: Create an ontology for owners and admins; members are told an owner or admin creates one); deleting an ontology drops it from the cached list before going to `/ontology`, so it never opens the one deleted. The Explorer (`/explore/{ontologyId}`, with `/records` and `/entities/{id}`) and Ask (`/ask/{ontologyId}`) are sidebar sections of their own, under the same scope; an Ontology picker beside the Load picker (`OntologyPicker`) opens the same page of another ontology (an entity's page: its graph), without `?load=` or the page's view. `/ontology/new` (`new-ontology.tsx`) creates an ontology and its first draft step by step on one page: Name, Database, Connect your data or upload documents, Goal (optional), Review and create, the current step open, done ones a line of what they chose with Edit, and Back and Continue (Skip on an empty optional step). The step is `?step=` (none for Name), so Back, Forward and a refresh keep it. `newOntologyWizard` in `model.ts` checks each step (`StepState`: ready with its summary, skipped, loading, or blocked with why), shows the step asked for unless one before it is blocked, says where Back and Continue go, and builds the request once every step is done. The choices, one client token and what an earlier attempt created stay in sessionStorage (`foundry.new-ontology.v1:`) until its first generation starts or Cancel; once the ontology exists they name it (`ontologyId`) for its overview, and a new wizard starts afresh. The database is an existing live tenant (`GET /v1/projects?workspaceId`, then `GET /v1/tenants?projectId` per project, as helix-website lists them) or a new one through `POST /v1/projects` and `POST /v1/tenants` (no clusterId: the shared cluster). A tenant's slug is unique across the shared cluster, which every workspace shares, so a taken one (`ALREADY_EXISTS`) is retried once with a short random suffix. Connect your data or upload documents is one step, Generate's connector and set pickers together (`generate-parts.tsx`, with its Goal panel; `generationSources` in `model.ts` checks it everywhere: the wizard, Generate and the overview). It needs one source, either kind: a synced connector, a table file that has been read, or a chosen set holding text; tables and table files together count toward 50. With no connector chosen it sends `noConnectors: true` (files alone; an empty `connectorIds` is every connector). A chosen connector on its first sync or a file still being read is waited for while nothing else is ready, and said in the step's `note` once something is: a generation started now leaves it out, so neither page sends anyone to Data: Add data opens the Data area's `ConnectData` over the page (`onConnected` keeps it there), the new connector's first sync shows in its row (the connectors query polls while one is live) and it counts as chosen once synced, while the wizard says "Syncing… you can continue once it’s synced"; Upload documents is the Data area's `useDataUpload` (General is made by a workspace's first upload) and chooses the set it went into. Create and generate makes the database if new (reusing the project or database an earlier attempt made), the ontology (the same `clientToken`, so a retry is the same ontology), then its generation, and opens the generation's page; a generation refused as already live (409, an earlier attempt's) opens that one, and any other failure opens the ontology saying why (`ArrivalNotes` in `parts.tsx`, from router state), whose setup carries on from the choices kept. An unfinished ontology's overview carries its setup on (`SetupFlow` in `setup.tsx`), in one card (the kit's `Panel`): its head says how many required steps are done and holds Set up with your agent, and its title minimises it to that head, which then names the step next (remembered per ontology in this browser, `foundry.ontology.setup.minimised.v1::`); what an open step shows sits flat in it (its callouts without their own cards, a failure apart). While `useOntologySetup` says it isn't set up, the wizard's rows (`FlowStep` in `parts.tsx`) show above the schema, or are the page (narrow) without a current version: Name and Database done, then Connect your data or upload documents, Goal (optional), Generate a draft, Review and make a version current, Load it, and Explore or ask, the first not done open, done ones a line of what they chose. Until a draft is under way, the sources and Goal steps are the wizard's pickers walked with `?step=` (`draftWizard` in `model.ts`, the wizard's checks), and Generate starts the generation in place (`generateOntology`; a 409 shows the live one); they start from this tab's wizard choices when they name this ontology, else the newest generation's (as its Try again), and then open on Generate. A live generation shows in its step (`LiveGeneration`), a failed newest one says why with Try again; drafted, the next step is the ready-to-review callout with Make current (`ReadyToReview`); current, Load it is `NotLoaded` (the Load dialog) or why the version can't be loaded; loaded, Explore or ask opens the Explorer or Ask, and is done once the ontology has a question or this browser opened its Explorer or Ask (`/` too) on a load (`foundry.ontology.explored.v1::`, written by their scope). What the open step shows isn't repeated above it, and the header's Generate shows once the ontology has a draft. Members see the same steps without pickers or anything that changes the ontology. Once set up, the overview is the schema alone. Helix gives a new database a first key whose token Foundry never keeps or shows; the page the wizard lands on says so once and links to the database's keys in the dashboard. Foundry never offers dedicated clusters or a cluster's default database. A workspace without a Helix subscription can't create a tenant (`FAILED_PRECONDITION`, reason `NO_WORKSPACE_SUBSCRIPTION`): the review step links to the dashboard's billing and offers an existing database. `/ontology/{ontologyId}/generate` drafts a new version of an existing ontology with the same pickers, prefilled from `?goal=&connectors=&sets=` or the current version's sets. Deleting an ontology deletes Foundry's rows and, in the background, what its loads wrote into the database; the database's other data, and the workspace's documents, stay. A version's `definition` is `foundry_v1` (Definition v1) or `agent_bundle` (the ontology agent's draft), told apart by `format` (a version without it, from a WFE before the field, is `foundry_v1`). `model.ts` reads both, parsing `ontology.toml` and `mappings.toml` with smol-toml; nothing trusts the shape. An agent source id is `src_` and 8 bytes of SHA-256 of `connectorId \0 table`, so `sources.ts` names sources from the connectors' synced tables and the version's generation tables; a table file's source id is its document id, named by the file and its set. A generation is named by its `title` (one line of at most 60 characters the WFE writes from the goal when it is created) wherever it shows: its page's heading and browser tab, breadcrumbs, the schema versions list (each version by the generation that drafted it, and the generations that failed), the live-generation callout and the page of the version it drafted; `generationTitle()` in `model.ts` derives one from the goal's first sentence for a generation without it, and the goal itself shows once, in its page's Goal panel. Versions are immutable. An agent bundle’s Edit tab stages per-item document routes, identifiers-only type changes, rules and constraints; it previews the exact batch through `:revise` before offering Create version. Both calls poll generations; a dry run stores findings/cards without a version. Bindings and structural schema changes stay with the agent (`CreateOntologyVersion` stays off the allowlist). Only a finished draft (`status` done) can be edited. The `:revise` route is in the vendored swagger (the backend `foundry-version-revise`, see `contract/wfe.swagger.ref`); an edit is `{put: {itemId, toml}}` or `{remove: {itemId}}`, and a revision generation carries `revision {parentVersionId, dryRun, resultJson}` (no `baseVersionId`); the Revise result rides in `revision.resultJson` as the engine writes it (3.9.0, with the `budget` finding and `unmeasured` saying what the counts leave out), on `GET .../generations/{id}` only: lists leave it out. An engine call a retry may clear leaves the revision queued ("the ontology agent could not finish the revision; Helix will retry"). The engine checks routes without the document sets, so the Edit tab refuses a route naming a set id the workspace lacks (`routeSetRefusal`). A revision is named by what it does (`generationTitle`: "Preview edits", "Edited version"), since the backend copies its parent's goal and writes no title. The staged edits and the running revision belong to the version page, so they outlive the Edit tab; a create that made a version clears the edits. `set-current` always sends the current version the page loaded, so a pointer someone else moved is refused. The graph is helix-foundry's read-only suggested-ontology schema (`suggested-ontology.tsx`), with no scene; a generation's and a load's pages show only foundry's data feed (`DataFeed` in `ontology-scene.tsx`: the steps and the sources read). Both CSS files are foundry's, with their colors made tokens. Read-only, a schema card opens its type (its name's link covers the card) and a link's tip shows only while the link is hovered or focused, never pinned.
- Prompts for the user's own agent (`ontology/goal-prompt.ts`, `onboarding-prompt.ts`, pure, in `test:coverage`): the Goal step's Copy prompt has a chat assistant write the generation goal from the chosen data; Set up with your agent (`agent-setup.tsx`, owners and admins, in an unfinished ontology's setup card's head, on no-ontology pages) has an agent with the Helix MCP (`NEXT_PUBLIC_HELIX_MCP_URL`, per stamp in `next.config.ts`) run the setup from where it is. Both share the data inventory and goal guidance. The onboarding prompt's "Connect your data or upload documents" step takes either, since a generation needs only one source (a synced table, a read table file or a set holding text), and its generate step sends `no_connectors` for documents alone. It names MCP tools, operations and payloads: keep them in step with the Helix MCP's Foundry tools and the `helix-foundry-onboarding` skill in HelixDB/skills.
- Documents (`src/web/areas/documents`, shown by Data): files of the workspace, each in one document set, a folder of the Data explorer. `DOCUMENT_FORMATS` in `documents/model.ts` is the one table of what the WFE stores: extension, media type, viewer and how ontologies read it (`kind`: text for Markdown .md/.markdown, MDX .mdx and text .txt; table for JSON .json, CSV .csv, TSV .tsv, JSONL .jsonl/.ndjson and Parquet .parquet; pdf for .pdf), text 1 byte to 1 MiB, the others to 3 MiB (`maxBytes`). A rename may change the extension, so the stored `mediaType` decides how a document shows and whether it is read. Each uploaded document carries its `read` (`readKindOf`, `fileRead`): text is ready as it is and loads read it; a table file is read once by the WFE's document worker after its upload completes (`READING`, `retrying` after a failure that isn't the file's, then `READY` with its `rows` or `FAILED` with why) and never joins a load as a document: it is a table of its own, its source id the document id, that a version's mappings name; a PDF is read once by the same worker (`READING`, then `READY` with its `pages`, its text extracted in reading order with a form feed between pages, or `FAILED` with why, which `fileRead` makes a sentence of: a scanned PDF has no text to read): once read, loads and generations read its text as plain text (`readsAsText`), and a load records a failed one with the read's code (`failureText` gives the read's message). The tree's tooltip says a file's rows, pages or read, a dot (labelled with the read's message) marks one that couldn't be read, and a table file's page has a Rows panel (`RowsPanel` in `data/document.tsx`): its rows, or that it is being read or why it couldn't be, and for each ontology whose current version reads its set whether that version maps it (`fileMapping`, `useFileMappings` in `documents/mappings.ts`), else Revise (its generation drafted from the file) or Generate. Delete and Move warn when a current version maps the file: its next load fails until the version is revised (`loadDocumentPlan`'s `missing`, which the Load dialog names and refuses). Uploaded in Data in four steps (SHA-256 in the browser, `POST .../foundry/documents`, an XHR PUT of the bytes to the presigned URL with progress, `:complete`; `uploads.tsx`), from Upload documents (the explorer's or the Data page's), a set's button, or a drop on a set or anywhere else on the explorer or the Data page (`data/upload.tsx`; each lists the uploads it started; the default set: General, else the oldest; a workspace with none gets General on its first upload). The bytes never pass through `/papi`. Names are checked as the WFE checks them (trimmed, at most 255 characters, no slash, backslash or control character; a rename may change the extension), and a file it refuses (`INVALID_ARGUMENT`) offers no retry. The same bytes anywhere in the workspace return its document (`existing`), even with an upload URL (another upload's pending document: the card PUTs and completes it): the card says which set it is in. Only a retry of the same card is its own upload; a batch refreshes the documents, sets and ontologies once it settles. `:complete` and `:move` answer `reads`, one per ontology whose live or current load reads the set (`QUEUED`, `OWED`, `NO_BUDGET`, `HELD`, `SKIPPED`, and `READING` for a PDF whose text is still being read, which the ontology reads once it is); `readsText()` says them in words, with the set's `readBy` for ontologies that store it without a route or read it on their first load. Set names are unique in the workspace, case and accents aside; a workspace creates up to 20 sets; an empty set is deleted. `/data/documents/:id` (`areas/data/document.tsx`) shows what it holds by its media type (`documentViewer`, never sniffed from the name or the text; `documents/document-view.tsx`, each viewer lazy-loaded): text types and a read PDF's text from `:content` (`{text}`), Parquet, a PDF's pages and a type the page doesn't know from `:bytes` (`{mediaType, data}`, base64, decoded by `documentQueries.bytes`). Markdown is rendered by react-markdown and remark-gfm (`documents/markdown.tsx`: no raw HTML, images as alt-text chips and never loaded, only http, https and mailto links, headings one level down); JSON is a tree that renders only open nodes, 200 children at a time (`json-tree.tsx`); CSV and TSV (papaparse, `delimited.tsx`), JSONL (`table-preview.tsx`) and Parquet (hyparquet with hyparquet-compressors, `parquet.tsx`) show their first 5,000 rows in the kit's DataGrid, columns from the rows, with "the first N of M" and the rows or lines that couldn't be read; a read PDF shows its text (Text: each page after a rule labelled "Page N", `document-view.tsx`) beside its pages (Pages, `?view=pages`, the only view while it is read or when it couldn't be, under `PdfReadCallout`'s reason); its pages must start with `%PDF-` and show in an unsandboxed iframe from a blob: URL revoked on unmount (`pdf.tsx`; the CSP sets only `frame-ancestors`, so `blob:` frames are allowed); an unknown type shows its bytes as text with a download. Markdown, JSON and the text tables have Rendered (Tree, Table) | Source (`?view=source`); plain text is shown as it is; then its set, size, Move and Delete, and its `ontologies` (each with `routed`, its current load's extraction and a link to `/ontology/:o/documents/:id`); it polls while one without an extraction has a job live. The palette lists documents. A version's scope (`documentSetIds`) is the sets its loads read: a generation's picks (Generate picks whole sets, at most 20, the WFE's cap; its choice is seeded once from `?sets=`, else the current version's scope, none for a first generation, and nothing can be picked or sent until those have loaded; it sends `[]` for none), a revision's its parent's plus the sets its routes name. Its routes (mappings.toml `[extractions.*]`, parsed in `ontology/model.ts`) say what each set's documents fill; a document of the scope in a set no route names is stored, not read (`out_of_scope`). The version page and the Load dialog name the scope (`scopeNames`, `loadDocumentPlan`). An ontology's documents (`/ontology/:o/documents?status=`, read only, its documents in the Ontologies panel's file system, with Open in Data; linked from the overview's header with `counts.documents` and from a load's Documents read) are its current load's and its reading version's scope's, filtered by the WFE; a load's Review documents opens `?status=review`. On a document's Data page, an ontology the set's `readBy` lacks only holds it in its current load: the next load drops it. A document's view there (`documents/document.tsx`) opens any document of the workspace in one view: its text as the load stored it, read from that database (not from the WFE), rendered as the document is (Markdown through `markdown.tsx`, whose `rehypeMarks` (`marks.ts`) draws the load's marks on the rendered text by aligning it with the source; plain text as it is; a PDF's text as plain text, each page after a rule whose number `sectionPages` counts from the sections' byte offsets in its `:content`), with the facts, entities, reviews and details beside it. Until a load holds it, it shows as uploaded with the Data page's viewers (`document-view.tsx`, no Source toggle). `#s{section}-{start}-{end}` (`paths.document`'s `span`) marks any span of a section and opens on it. A click on a mark goes by its most specific mark (`markTarget`): a name opens its entity, a quote its fact's entity (or, while the facts load or when it joins none, the fact in Facts), a review its card in Needs review; a link's span alone, or a mark inside a Markdown link (`rehypeMarks`' `dataInLink`), stays plain.
- Connector documents: a connection's syncs write documents into sets of its own, one per stream (`FoundryDocumentSet.connector`: `connectorId`, `stream`, `maxDocuments`, `connectorName`), each document with its `source` (`externalId`, `version`, `url`, `at`, its time at the source). Granola writes each note's summary into Notes (Markdown) and, with `GranolaConnectorConfig.transcripts`, each transcript into Transcripts (plain text); changing that setting queues a full sync, and turning it off empties Transcripts but keeps the set. A set keeps the newest `maxDocuments` (1,000) by `at`. They are read-only: the WFE refuses an upload, move, rename or delete in or of one with FAILED_PRECONDITION ("this set belongs to the Granola connection; change its settings there"), so `defaultSetId` skips them (a workspace with only those makes General on its first upload), they are no drop or move target, and a document's page has no Move or Delete; it says its connection, set and time at the source, opens an https source page (`noopener noreferrer`), and says "Replaced or removed at the source" once deleted. They show as " / " beside other sets (`setLabel`). `ListFoundryDocuments` without `setId` or `ids` lists uploads only, so the Data tree pages a connection's set (`documentQueries.set`, 50 at a time, newest first), counts come from the sets, and pages naming documents by id read the ones the uploads lack with the `ids` filter (`documentQueries.byIds`, `useDocumentNames`: repeated `ids=`, at most 100 a call, either origin, deleted ones included, one page, never beside `setId` or `pageToken`; the same route, so no allowlist entry). The palette lists uploads only; connector documents are reached under their connection. The connection page lists its sets with their counts ("Keeps the newest N" at the cap), Generate's picker groups them under the connection (`pickerSets`), and both, like the tree, re-read the sets when a sync ends (`useSetsAfterSyncs`). In the mock (`data-sources.ts` `documentStreams`, `documents.ts`), a Granola sync writes 418 notes, with the writer release's headers and set descriptions, and their transcripts when on. A note whose title contains "edited" ("Pricing review (edited)", the third newest) is edited at the source before every sync, so each sync replaces its document with a new id and the old one becomes deleted. `{"documentsPerStream": n}` sets the cap (1000).
- Sources (`ontology/version-agent.tsx` `AgentSources`, `sourceMatrix` in `ontology/model.ts`): a version's per type and link view of what fills it, derived from its two files: each source table's mappings (creates, looks up, props filled) and each document route (creates or finds, props, links) by the sets it reads. ontology.toml's typed `identifiers` show on the Types tab.
- Property provenance (`explore/model.ts` `propertyViews`, `entity.tsx`): an entity's page reads what states it (`FOUNDRY_ABOUT`: rows, whose `props` say what they set, and property quotes) and shows each property's row or quote (a quote opens its span); values documents disagree on show with every quote. A mention's `resolved_by` shows in words (`resolvedByText`). `__id` and Foundry's stamps (`foundry_*`: `foundry_load`, `foundry_tx`, `foundry_part`) never show among its properties or its facts'.
- Loads (`ontology/loads.tsx`): a load is one write of a version into the ontology's database; its id is its load job's, and every node and edge it writes carries that id as `foundry_load`, so loads of any versions sit side by side. Load on the current version when it is an agent draft whose status is done, behind a dialog with what it reads and the caps ($20 a load, $100 a workspace a month, 200 documents of 1 MiB). One live load or extract per ontology (a delete gives way). The newest load that finishes ready becomes current (a partial one only when nothing is); reads and uploads go to the current load, and a document's `extraction` is the current load's. The Loads page lists every load kept (the current one and the newest others, five in all) with its version (`foundry-v`, shown `vN`), status, node and fact counts (bitmap counts in the load), jobs (the WFE lists a load's newest 20, with the count and spend of all) and retention, and offers Open, then a row menu ("More actions for vN", shown only when it holds something) with Compare with current, Set current (ready or partial) and Delete (never the current or a writing load; the confirmation says edges added to its nodes go with it), or Retry delete for `delete_failed`. A load whose tables were synced again while it ran (the WFE's `sources_changed` words) offers a new load. `/ontology/{id}/loads?new=1` (`paths.newLoad`, the Load data of the Explorer's and Ask's empty state) opens the Load dialog once, when the header offers Load, and drops the parameter. A member who can't read the database's project gets the ontology without `projectId`, `tenantId` and `databaseName` (in the mock, `{"ontologyDatabaseHidden": true}`); its document text, entity pages and Explorer say so.
- Choosing a load (`ontology/load-selection.tsx`, `explore/load-scope.ts`): the Explorer, Records, an entity's page and a document's view read one load: `?load=` or `?load=other` (Other data: what no load wrote), absent for the current load, else the newest. With no load at all (`chooseLoad`'s `none`: the loads list is empty, or, until it is read, the ontology has no `currentLoad` and no `lastLoad`, so nothing flashes first) the Explorer, Records, an entity's page and Ask's new thread read nothing and show `NothingLoaded` (`load-selection.tsx`): Load data (`paths.newLoad`) for owners and admins while a version is current, else Finish setting up (the overview, the setup flow); members get the same words without the action. Only an explicit `?load=other` reads Other data then (the picker hides with no loads). A picker in each header lists the loads (written and deleting ones disabled); links between the views carry `?load=`. Every read is scoped: a label source is `$label == x and foundry_load == L` (two constants, two bitmaps); nothing under a load reads `nodes`/`edges` `all`; Other data counts a label less every load's and pages a label a version names by `is_null(foundry_load)`. Every page of an ontology reads its database through one limiter (`explore/tenant-reads.tsx`, 1 request a second, burst 2), and the end of any job refreshes every read, also when the next job of a chain starts at once (`scope.tsx` watches the jobs the loads list names). When the loads, or the versions Other data is told apart by, can't be read, the page says so with a retry and reads nothing.
- Explore (`src/web/areas/explore`): reads of an ontology's database with the user's session through `POST /v1/databases:query-read` (`database-query.ts`, helix-website's transport, reads only). Its section has a `page` panel, beside the sidebar. Its views share one slim bar (`top-bar.tsx`: the Records/Graph switch, the Ontology, Load and, on the graph, Compare pickers; an `h1` for screen readers), so switching moves nothing. On one load the graph is the page (`DatabaseExplorer` `frame: { kind: "page" }`): the bar also holds the explorer's tools (Jump to a node, what the canvas holds, reload, shortcuts, and the inspector's toggle); its labels (filter, Show Foundry internals, node and edge labels with their counts and loading, Other data) are the panel's, and inline only while fullscreen; its inspector (the overview with the database and Read only, or what is selected) is the right-hand sidebar under the bar, scrolling on its own, hidden and shown from the bar or `]` and remembered per database as before (`[` hides the panel). On a phone the labels are in the navigation drawer (the Labels tab opens it, picking a label closes it) and the inspector a sheet. A compare's two sides are each `frame: { kind: "side" }`: a bordered box with its own bar, labels and inspector laid out as on a tablet (over its canvas). Where no page fills the panel (the page loading, a compare, an entity's page, a database that can't be read, nothing loaded) it shows the ontology, its database, the load read and links to the views (`panel.tsx`). In a load, the labels list the load's version's types and links counted in the load, with Other data as a group below (shown only when picked); the first view is seeded (`explorer/lib/seeding.ts`): candidates from the hub types, a bounded probe (20 nodes a request, stopping at a slow batch), seeds on the canvas first, then a ring of neighbors per link, a second ring within a budget (~320 nodes), and the edges among them; a node is read around or connected only once its edges are counted small, as the explorer plans connects. Show more reads the next ring. Search filters to the load (text index hits are over-fetched and filtered). A node reached outside the load is drawn dashed and the inspector names its load. `&compare=` draws two loads side by side over a diff (`compare.ts`, `compare-panel.tsx`): counts per type and link, and a type's entities by key (at most 5,000 a side) when opened. `model.ts` holds the v3 read queries over the ontology agent's graph layout (C3) for a document's view and an entity's page, with their parsers; `explorer/` is helix-website's graph explorer (labels, canvas, inspector, search, context menu) without its query dock, restyled with the kit's tokens in `explore.css` (classes `ex-`). The page uses no Tailwind utilities: Foundry's element CSS outranks them.
- Foundry's own labels: a load writes its bookkeeping (documents, sections, mentions, evidence, row chunks, reviews, sweeps, violations, transactions and versions) beside the ontology's entities and facts, every node label `Foundry` and every edge label `FOUNDRY_`; the ontology agent refuses ontology types and links with those prefixes. Every label the website reads or the mock writes comes from `src/shared/foundry-labels.ts`, and `isFoundryLabel` tells them by prefix. A Foundry label is never an entity, a fact or a Records type. The Explorer leaves them out (the rail, totals, search, canvas, expansions and relationship counts: `explorer/lib/exclusion.ts`, and `starts_with` on `$label` in the reads of a node's edges) until "Show Foundry internals" under Labels, remembered per browser (`foundry.explorer.showInternals`, off by default); turning it either way reloads the graph, and shown they are listed apart and drawn grey.
- A new database is starting: for its first minutes the gateway answers a tenant it hasn't taken on with 503 `rate_limit_unavailable` (the broker's cluster-scoped key passes auth for any tenant, so 401, 403 and `backend_unavailable` are platform failures, never "starting"). Within 15 minutes of the ontology's `createdAt` (the target every read carries: `ontologyDatabase` in `reads.ts`, under the shared limiter of `tenant-reads.tsx` and the Explorer's own) the transport makes that `kind: "starting"` with `retryInMs` (5 s, doubling each minute of the database's age to 30 s; `database-query.ts`). Every read query spreads `readOptions` (`reads.ts`), which retries it inside the query after that wait, so between attempts it stays pending with the failure as its `failureReason`; its page checks `startingOf(error ?? failureReason)` before its skeleton and shows `` (`explore/starting.tsx`) in place of the error card, whose Retry resets the query (a refetch would only join the waiting retry); the Explorer shows `StartingState` and boots again by itself. After the window the same answer is the error card. A queued load or extract whose last attempt failed at the database in that window says the database is still starting (`loadRetry` in `ontology/model.ts`: the live-load callout, the load's page and the Loads page's Jobs column): the worker words that failure as any refused write.
- Paused queries: a database whose workspace's Helix billing paused its queries fails as `kind: "disabled"`: the WFE's 400 FAILED_PRECONDITION "tenant queries are disabled" or "queries … are paused: " (no reason code; the transport keeps those words as the message), or the database's 402 or `tenant_disabled`. `queriesPaused` (`database-query.ts`) says `no_plan` for "the workspace has no active plan" and `billing` for the other gates. No read retries it. The Explorer shows `BillingPausedState`, Records and an entity's page `DatabasePaused` (`explore/starting.tsx`, from `pausedOf` in `reads.ts`): "Queries are paused: this workspace has no active Helix plan" with Choose a plan, else "Queries are paused for billing" with Open Helix billing, both to the dashboard's `//billing`, and no Retry.
- AI models (`src/web/areas/workspace/ai*.ts*`, Settings › AI models): hosted, Helix's Claude is the default and an owner may bring a Claude, OpenAI, Azure OpenAI or Amazon Bedrock key, or a custom OpenAI-compatible server, on Pro or Max (`FOUNDRY_PRO_REQUIRED`), which is also refused when used once the workspace leaves Pro: the setting reads as paused, and `` shows ``, until an owner chooses Helix's Claude (its work never moves there unasked); locally, the provider the install's `.env` names (`FOUNDRY_AI_PROVIDER`), else the one it found on its machine (`detected`, whose `summary` the page shows in the default's place: opening the page as an owner calls `DetectFoundryAIDefault` once, and keeps its answer unless the setting changed meanwhile), or none, and a key needs no plan. A save is tested before it is used (`pending`, read every 1.5 s) and the setting in force stays until it passes. Without a provider (local only) the WFE refuses generating, asking and completing an upload with `FOUNDRY_AI_NOT_CONFIGURED`: `useAiConfigured()` (true when the setting can't be read: the WFE still refuses) and `` say so, `` in place of a generate or Ask error, and failure copy naming "Settings › AI models" links there (`SettingsLinked`). Kinds the WFE offers without a form here are left out (`FORMS` in `ai.tsx`). A task's model comes from Helix's price table (`contract/ontology_agent/catalog.json`, by kind) or is another one at the owner's rates; an Azure OpenAI deployment is priced as a table model (`tableAs`) or at rates, and its key is kept only for the same resource and draft deployment (`canReuseKey`). A custom OpenAI-compatible server (`CompatServer`) has a base URL including `/v1` (hosted: https on a public address, as the WFE checks; locally http and private addresses too, `host.docker.internal` for this computer), an API format (Chat Completions or Responses), calls at once (4, at most 64) and an optional key kept only for the same normalized base URL; its models are named by the owner and either count no cost (`free`, the start) or the owner's rates, and failure copy and toasts name it by its host (the WFE's `label`). Saving another Azure resource or custom server confirms like another kind: their model ids name the endpoint (`idEndpoint`), so documents are read again. Ollama is a local build's only, first of its cards (hosted hides it even if offered): a server (Bundled `http://127.0.0.1:11434`, the compose file's `ollama` profile; On this computer `http://host.docker.internal:11434`; or Other), its models at no cost (`free`), chosen among those the server has (`ListFoundryOllamaModels`, `ollamaChoices`: those Foundry can run first, with their sizes, the rest disabled with the WFE's reason, and the server's `problem` said as it is; Other's address is read once valid, after 500 ms) or by name (Another model, for one not pulled yet), following the server's most capable usable model (`ollamaBest`) until the owner picks one, else `OLLAMA_MODEL`, the install's candidate default (`FOUNDRY_OLLAMA_MODEL` in local/compose.yaml), and a context window (65536, drafting's and the least the WFE saves: questions need 32768, the other tasks 16384); it takes no key, and `OLLAMA_FAILURE_COPY`/`OLLAMA_DETAIL_COPY` word its failed tests. Amazon Bedrock runs in a region (`BEDROCK_REGIONS`, AWS's table of where `bedrock-runtime` answers, FIPS where AWS has an endpoint, or another typed by its code) on model or inference profile IDs: a profile without a row of its own is priced as its model, its `global.`/`us.`/… prefix taken off (`pricedRow`, as the agent's `llm::catalog::find`), another ID as a table model or at rates; its key belongs to the AWS account, so any Bedrock save keeps it. The WFE suggests Claude through `global.` profiles, which may run requests in any AWS commercial region, so its card and setting say that (`disclosure` reads the models: a geography's profile stays within it) and the Region field says GovCloud has none. In the mock (`mock/routes/ai.ts`) a test ends `aiProbeMs` (1500) after the save: a key containing "bad" fails `auth`, a model (or Azure deployment) containing "notools" or "missing" fails, `aiProbeOutcome: "timeout"` times out, a custom server's save is refused in the WFE's words, its DNS making hosted `.internal` names private and `.invalid` ones unresolvable (locally, a `.invalid` server fails its test `unavailable`), and Ollama refuses a window under 65536 at save and fails on a model containing "short" (`context_too_small`) or "changed" (`model_changed`); the local mock's default is `MOCK_AI_DEFAULT`, found on the machine (`detected`) unless `aiDefaultNamed: true` (its .env names it: no `detected`, and detect is refused); `aiDefault: false` takes it away, `aiDefaultKind: "ollama"` makes it Ollama, `aiDetect: "anthropic" | "ollama" | "none"` is what detect finds from then on, `ollamaBundled: false` stops the bundled Ollama, and an Ollama server's models go by its address (a host ending `.invalid` doesn't answer, one containing `notollama` isn't Ollama, one containing `old` is 0.5.7, too old to tell its models apart); `freshInstall: true` replaces Acme with a new install's My workspace; `ontologyOutcome: "auth"` fails a draft on the workspace's key, and billing's `foundryPeriodEnded: true` ends a cancelled plan.
- Ontology in the mock (`mock/routes/ontologies.ts`, `ontology.ts`, `ontology-agent.ts`): Acme has "Sales graph", on Production's `sales` database, which maps the Shop database's `public.people` (`mock/fixtures/sales-graph.ts`): version 1 maps it, version 2 (current, never loaded) also enriches Person, as the agent suggested. "Customer contracts", on its Production project's `contracts` database, has version 1 (Definition v1 on Stripe, current), version 2 (an agent bundle from an ontology-agent bench run, on the Shop database) and version 3 (an agent draft for contracts with the Stripe customers, done: the one to load; its two competency questions are Ask's suggestions), plus their generations. A third, Renewals, is under Documents and loads below; its newest generation failed. A generation drafts from Data's `workspaceConnectors` and the read table files of its sets (mappings naming their document ids), or from text alone (`noConnectors`), is queued for 1.5 s and runs as long again; `POST /__mock/config {"ontologyStepMs": 300, "ontologyOutcome": "fail"}` changes both (`"retry"` fails the first attempt the way the worker retries, then succeeds). A queued generation with `attempts` above 0 is retrying: the pages say why and when it runs next. A new generation's title is the mock agent's `draftTitle(goal)` (none without a goal), and the oldest seeded generation has none, as one from before titles.
- Helix databases in the mock (`mock/routes/databases.ts`): Acme's Production holds `contracts` and `orders` (the team's own accounts and orders), Staging holds `sandbox` (empty) and `old-orders` (deleted). A new database needs a Helix subscription: Acme has one, and a workspace that never had one gets its free Helix plan with its first new database, as the WFE grants a Foundry workspace's, so a new workspace creates its first ontology on a new database (`createFirstOntology` in `tests/e2e/support/mock.ts`). `{"helixSubscription": false}` lapses every workspace's plan, which is not granted again: a new database is refused with `NO_WORKSPACE_SUBSCRIPTION`. Each tenant holds a graph (`mock/graph/store.ts`); `databases:query-read` runs the v3 read request over it with `mock/graph/query.ts`, which answers every operation the explorer and the reads send and refuses any other with Helix's 400 `invalid_query_json`. `{"startingQueries": {"": n}}` makes that database refuse its next n reads, and a load's ingest attempts, with 503 `rate_limit_unavailable`, as a starting database does.
- Documents and loads in the mock (`mock/routes/documents.ts`, `loads.ts`): `/__uploads/` stands in for the bucket (CORS that allows only `content-type`, as the real bucket's does, and S3's checks of a presigned PUT, whose checksum is in the URL; `{"documentUploadFailures": n}` fails the next PUTs). A load writes the ontology agent's layout through one load's view of the graph (`mock/graph/store.ts` `LoadGraph`: every node and edge gets `foundry_load`, every lookup sees that load only, and a `FoundryLoad` marker lists its labels; `mock/graph/extract.ts`, with the labels of `src/shared/foundry-labels.ts`): Stripe customers and invoices for mappings that read them, then each document's sections, mentions, quotes and reviews; a company name the database doesn't hold (the seeded `mock/fixtures/contract.md` names Globex and Initech, two Stripe customers, and Umbrella Holdings) becomes a review, never an entity. Documents and sets are the workspace's (`mock/routes/documents.ts`): Acme starts with General (`set_acme_general`), which Customer contracts' version 3 and Renewals (a second Acme ontology, older, on the subscriptions database, whose current version 1 is the same contracts draft) read; a new workspace starts with no set. Under `pnpm dev:mock` (`MOCK_DOCUMENT_FIXTURES`), General also holds `renewals.csv`, `pricing.json` and `renewal-terms.pdf` (two pages), to show their viewers, and `signed-order-form.pdf`, a scan whose read fails; tests start with no document. The mock takes the formats of `FORMATS` (1 MiB of text, 3 MiB of the others), refuses `:content` for Parquet ("Parquet documents have no text; use :bytes") and for a PDF until its text is read, and serves `:bytes` for any. It reads a PDF's text with `unpdf` (a dev dependency, in `mock/pdf-text.ts`, whose `textPdf` builds PDFs for the fixture and tests), its pages joined by `\f`, after `documentReadMs` as a table file: each page normalised as the WFE stores it, and fails in the WFE's words: "Scanned PDF: no text to read" for one with no text, a damaged or locked one, or one past 150 pages; a load records a failed one with the read's code, for good, at no cost. A PDF still being read joins its set's readers' loads (`READING`), which read it once its read is done (`wakeReads`, run by `advanceLoads`). Every version has a scope; a load's documents are its members, the uploaded documents of its version's scope when it was created and those that joined since, each read by the routes of the set it joined with (`mock/graph/scope.ts`): in a set no route names it is stored with its sections and nothing read; in scope the Contract gets its signing date and term with property evidence, set from the whole set of evidence (documents that disagree leave no value and a `prop_conflict` review), and a quoted customer id finds its customer by identifier. `{"loadStepMs": 300}` paces it; `loadOutcome` `fail`, `sources_changed` (re-synced mid-load) or `budget_exhausted`, `documentOutcome` `fail` and `deleteOutcome` `fail` end it otherwise; a database still starting (`startingQueries`) refuses an ingest's first write, and the load waits, queued, to try again. The mock keeps several loads per ontology as the WFE does: current load, retention of five, delete jobs (a new load cancels a queued delete, which runs again after), and an extract of the current load after an upload or a set-current that leaves documents unread. `:complete` and `:move` fan out across the workspace's ontologies as the WFE does (`documentArrived` in `loads.ts`), oldest ontology first, after bringing every ontology's jobs up to now; `ontologyDatabaseHidden` makes each one `SKIPPED`. As the backend: a version's scope keeps the sets live when it is stored, a revision reads its parent version's scope, a client token finds even a deleted document, a repeat create presigns another URL while earlier ones work, the 200-document cap counts uploads still within 15 minutes, a set create checks the 20-set cap before the name and a set's own name is no rename, and generate checks a live generation, then connectors and tables, then sets (at most 20). `{"legacyDocumentSets": [names]}` gives Acme sets past the cap or with a taken name, as a legacy merge may.
- Rules on read (`explore/graph-read.ts`, `src/shared/foundry-read.ts`): `POST .../loads/{loadId}:read` runs the ontology agent's reads (`readsJson`, at most 20) over a load with its version's rules evaluated, never stored; results come back as `resultJson`, one outcome per read, each parsed by its read's parser. Only a version with rules, constraints, relations or link declarations (`rulesOf`) is asked. An entity's page has a Derived tab (`explore/derived.tsx`: flags and derived facts, each with Why, the proof down to stated facts; a pred too costly to compute says so) and Needs review adds rule breaks found on read that the load keeps no record of (self-links). A load's Rule breaks page (`ontology/load-rule-breaks.tsx`, from the load's page) counts and pages the `FoundryViolation` records through query-read (`explore/rule-breaks.ts`), from a cited record with `?constraint=&key=`, and says what the load's checks leave out (the engine doesn't record those counts in the graph). Entities in a read are `{type, id}`; pages name the ones they don't show with one lookup per type. The mock evaluates the version's rules over the load (`mock/graph/rules.ts`), sweeps violation records at the end of each load and extract, refuses reads that don't fit `read_request.json`, and takes `{"readErrors": {"derived:x": "budget"}}` (or `"violations"`); the contracts draft (version 3) has a symmetric relation, flags and a constraint that 62 invoices break.
- Shell (`src/web/shell`): the labelled sidebar is out on every page, Settings too, with no collapse (`Sidebar.tsx`: the mark and search, the workspace, a link per section (`nav.ts`) with its name and its areas' `subnav`, what the open section shows under the sections, then the account and settings; while the workspace is onboarding only the mark, the workspace and the account). A section given `sidebar` content in its area's `AreaShell` (`sidebar: { ask: { label: "Ask threads", component } }`, `types.ts` `SidebarSection`; lazy) shows it in the sidebar under the sections while it is open, in a region named by `label` that fills the rest of the sidebar and scrolls on its own; only Ask has one (its threads: New thread, the threads by recency, the month's spend), on `/` too. A section given a panel (`panel: { data: { kind: "component", label: "Connectors explorer", component } }`, `types.ts` `SectionPanel`; components lazy) shows it beside the sidebar, 280px, scrolling on its own, as an `` named by `label`: two columns left of the page. On a phone the drawer holds the sidebar, or, in a section with a panel, the rail of section icons (`Rail.tsx`, the drawer's only use of it) and the panel side by side. A panel is one of two kinds. `component` (the Data explorer, the Ontologies panel): the shell renders it outside the page's routes, so it reads what it shows from the URL and the query cache, and calls `onNavigate` from its links, which closes the phone's drawer. `page` (the Explorer's): the open page fills it with what only the page knows, by rendering `` (`context.ts`), which portals its children into the panel while the panel shows (beside the sidebar, or in the phone's open drawer; nothing while hidden) and names the `` `label`; the children keep the page's state and context. `usePanelSlot()` gives the panel's element and, in the drawer, `onNavigate`, or null when it isn't on screen (a page then shows the content itself or offers the panel). The panel's `fallback` (a component as above) shows while no page has content there: loading, or a page with none. One `PanelContent` per page. There is no toggle for the panel: it always shows. `useShell().togglePanel()` (the Explorer's `[`) hides and shows it for the visit, so another section, or a reload, has it back (on a phone, it opens or closes the drawer). Three sections have one: Data (the Data explorer, `panel.data`) and Ontologies (the Ontologies panel, `panel.ontology`, which replaced the sidebar's `subnav` of an ontology's pages), both `component`s, and the Explorer (`panel.explore`, a `page` panel); no primary section has a `subnav` now (Settings' feeds the command palette).
- Ask (`ontology/ask.tsx`, `ask-model.ts`): questions about one load, run by the backend's ask worker as the engine's `Ask`; the page polls a question's events (`ask_event.json`) and reads its result (`ask_result.json`). A `tool_result` has `evidence_id` (a recorded read), `error` (failed: the step says why) or neither (the `answer`/`cannot_answer` call: done). Two recorded reads show their summary, and no result JSON, since the event carries none: `load_profile`, the engine's read of what the load holds (type counts, open rule breaks, top values, date ranges), recorded as e1 after `started` and before the model's first call ("Read what the load holds", input `{}`, so no JSON), and `helix_query`, a Helix v3 JSON read the model wrote itself ("Queried the graph directly", its `query` shown as code, `graphQuery`, its limit in a line above); neither is in `queries`, so neither has a Result or visuals, and a result citation of either lights its step like any other. A `find_entities` without a `type` searched every type ("Searched every type", "Matching “Acme”"). Answers render as plain text with citation chips only; each citation target opens its entity, fact (Facts tab), derived fact (Derived tab), row's table, document span, rule break (`loadRuleBreaks` with `key`) or the step whose result it cites, and a target with no page (a version of a violation record, a row of an unknown table) opens the step its citation came from (`evidence_id`); a recorded version (`version`, from the engine contract 4.1.0, not yet in the vendored schemas) reads "As recorded : " and opens its subject as the load holds it now (a prop its entity, a fact its subject's Facts; a violation record has no page). An entity a citation names (a fact's ends, a derived fact's args and a recorded version's subject too) reads as its page names it, from one lookup of the cited entities in the question's load once the answer is near the view (`readQueries.names`, in the background), else as a step looked it up by name, else by its id (`knownOf`); the steps' inputs name them the same way. An answer's visuals (contract 3.11.0: `answer.visuals`, at most 4, each naming a query result whose `queries[].output` the engine recorded) are drawn from that output, never from anything the model wrote: `ask-visuals.ts` decides by the output's shape and the `kind` hint (a count or aggregate is a figure; groups are bars, sideways for long or many labels, or a line on a time axis when they are every date there is (the output holds the most first, so some of them are bars, saying why); rows are a table, or bars or a line when asked, a line of some rows only when ordered by its dates; a kind that doesn't suit is a table), and `ask-visual.tsx` draws it right after the block its `after` names (after the last without one), outside the text's measure: a title, a Chart/Table toggle when a chart applies (recharts, lazy in `ask-chart.tsx`, one series in `--hf-chart-1`, tooltips with exact values), the kit's sortable DataGrid, entity cells and bars that open the entity in the question's load, and Show the query (lights its step). Each query step has a Result disclosure drawing its output the same way. Every value is text; a document's (`{"untrusted": …}`) is set apart in italics. The engine wraps every group's value that way, whatever gave it, so a group's isn't, and groups that are numbers as JSON writes them are a number column; the mock sends them so. A malformed visual, or one whose result has no output, is left out, and an unknown kind is no hint. Question lists leave outputs out (GET of one question keeps them), so an answered question with visuals near the view, or a step's Result opened, reads its question once (`questionQueries.answer`, keyed outside the ontology's keys, which asking and a job's end invalidate); a result from before 3.11.0 reads as text, its Results saying they weren't kept. A question's `focusJson` is an EntityRef as JSON; its events page carries the question's `status` and `lastSeq`, and the page polls until the question ended and its events reach `lastSeq`. A queued question with `attempts` above 0 is retrying, and `error` says why. Refusals (30 questions an hour, the month's budget) are the WFE's messages, shown as sentences. The page is laid out as a chat app: its threads (`ask-threads.tsx`) are in the shell's sidebar under the sections, with New thread, the threads by recency (`threadGroups`: Today, Previous 7 days, Older) and the month's spend in a line (its limits behind it); a thread is a slim bar (its title and the Load picker), the conversation in one 760px column, which follows its end until the reader scrolls up, and the composer pinned under it; a new thread asks "What do you want to know about ?" over a centred composer, with up to four competency questions of the load's version as suggestions (`askSuggestions`), only when that version is already in the query cache (a version can be large). On a narrow screen the page shows the threads until one is chosen (or there are none), and `?new=1` opens the composer there (`askView` decides, for the page and for New thread's current state). With no load at all a new thread shows `NothingLoaded` in place of the composer. The mock (`mock/routes/questions.ts`) answers from the loaded contract in the engine's shapes, starting every question with `load_profile` (the engine's summary, "N of N reads", from the load's types and broken constraints) and counting the subject's type with a `helix_query` its answer cites ("a value", as the engine words a count), every query's output computed from the load (when it holds invoices, it also counts them by status and ranks the largest, shown as two visuals after the paragraphs that introduce them), leaves outputs out of lists as the WFE will, runs 2 questions at a time per workspace (the rest wait, queued), and takes `{"askStepMs": 250, "askOutcome": "cannot_answer" | "budget_exhausted" | "failed", "askMarkup": true, "askVisuals": false, "askSpentUsdMicros": n}` (`askVisuals: false` answers as a 3.10.0 engine did, without outputs or visuals).
- Theme (`src/shared/theme.ts`, `src/web/shell/theme.ts`): System, Light or Dark from the account menu or the command palette, kept per browser in `localStorage` (`foundry.theme`; System clears it). The root layout runs `THEME_BOOT` inline in ``, which puts a stored choice on `` as `data-theme` before first paint (sign-in included); System leaves it to `prefers-color-scheme`. What draws its own colors follows a switch: the explorer's canvas reads `CANVAS_THEME` per frame and redraws through `subscribeTheme`, components re-render with `useCurrentTheme()`, entity type hues are the `--hf-type-*` variables, and PlanetScale, MySQL and PostHog have `-dark.svg` logos (`logoUrl` in `kit/SourceLogo.tsx`). `tests/e2e/theme-pages.spec.ts` saves every main page in both themes to `test-results/themes/` and fails on text under AA contrast.
- People enrichment (`workspace/enrichment.tsx`, `enrichment-api.ts`; `ontology/version-edit.tsx` `EnrichPeople`, `edit-model.ts`, `model.ts`, `loads.tsx`; `explore/entity.tsx`): an owner allows it in Settings › People enrichment with a monthly credit cap (at most 100,000; every change sends the notice version, off included, or the WFE refuses it); admins and members read it, and Free sees ``. Off Pro, while Helix pauses lookups (availability DISABLED) or while it can't read the workspace's plan (UNSPECIFIED, or unset), the owner may only turn it off, and the page says why. PATCH writes the whole setting with no expected state, so a new cap reads the setting first and isn't sent once someone turned it off meanwhile (only the WFE could close the gap between that read and the PATCH). A version enriches through one `[enrichments.]` item in mappings.toml: the agent suggests it (with its measured `clues`: High, Medium or Low confidence) or an owner or admin turns on **Enrich people** on a type in Edit, which stages one type put, the new link and the item and saves them as a version (**Save as version N**, then **Make current and load**). The form starts from the pending edits and stays open until the version is saved, so a refused save keeps its choices; set back to the version (off, say), it unstages that try, and undoing its item, new link or type put in Pending edits stages the enrichment again as the rest leave it (`undoEdit`: the type's undo keeps what the item needs of it and drops identifiers staged by hand). Taking a try back restores the type's identifiers from before it (`handIdentifiers`), which the version page keeps next to the edits (`TryBase`), since the edits can't tell an identifier the try declared from one staged by hand. Staging the type's identifiers leaves its card open with the form's unsaved choices, and makes an unstage they held back. A file enriches one type, so another type's switch stays off while one is enriched. A load's `enrichment` step reads `preparing`, `queued` (parked for a lookup slot), `running` (with **Stop looking up**), then `done`, `partial` with `stoppedBy`, `off`, `failed` or `cancelled`; its counts are int64 strings, `people = skipped + carried + toLookUp`, `carried` counts every earlier result reused (not found included), so "found before" is the applied profiles less this load's `found`, except for a step that published after its load stopped waiting for it: the load applied only profiles found before, and `found` is for the next load. The WFE doesn't flag that step, so `stoppedWaiting` tells it from a step still queued or running while its load job is past its first step or has ended (it reads "Still looking up after the load stopped waiting", without its counts), or from one that published after the load ended; one that published while the load went on reads as on time until the contract says so. A stopped step reads "Stopped after N", not "Stopped by Ada Lovelace after N": `FoundryLoad.enrichment` names no user (the backend keeps who in `stop_requested` but doesn't send it), so naming them waits for a contract field. A value a profile stated carries the "People enrichment" chip, linking the load. Ask cites a profile without its id (contract 3.12.0: an empty `row_key` for `foundry_enrichment`, and only for it), as "A People enrichment profile" opening the load.
- People enrichment in the mock (`mock/routes/enrichment.ts`, `loads.ts`): the operators' flag is on unless `POST /__mock/config {"enrichmentFlag": false}`, which reads DISABLED, refuses turning it on or a new cap (off still works), ends steps `partial(flag)`, refuses enrichment edits in Revise and keeps the agent from proposing it. The planner counts as the backend's does, the agent's measure of an item's clues (`measureClues`) as the engine's Clues does, without the planner's filters (so the Sales graph measures 12 of 14 usable while a load looks up 9), and `fakeEnrich` decides by the clue's text ("bad" not found, "low" scores 72, "dup" shares a profile, "other" fails the name check, "timeout" is uncertain, free-mail and role addresses are skipped). A load of the Sales graph looks up 9 of its 14 people, 2 a batch: Ada, Grace, Alan and Katherine are found, 4 are held and Bea isn't found (160 credits); the next load carries all 9 and doesn't park. `POST /__mock/config {"enrichmentStepMs": 300, "enrichmentOutcome": "cap"}` sets a batch's time (and the wait for a slot) and how the lookups stop (`cap`, `provider_credits`, `provider`, `time` or `busy`). A step that ends wakes its parked job, which stays queued at `provisioning` a step until it is picked up again, as the WFE's wake leaves it. Turning consent off cancels live steps and purges each ontology with a load that applied profiles, current or not (a load still writing counts), as the WFE does: once no job is live, a current load holding them is loaded again without them, the others holding them are deleted once a clean load is current, and none of them can be made current.
## Review decisions
- Reviews use their `review_key` for settle calls. Send only a `FoundryReviewChoice`; the backend derives the decision identity from the current load. The list returns flat camelCase decisions, 100 a page by last decision id, with no filter, so pages read it all; settling never waits for it. Only the current load can be settled; decisions and their removal apply from the next load ("Reload to apply"). A load reads the decisions saved when it was created (the backend snapshots them, and so does the mock, catch-up extracts included), so a decision saved before the shown load started was read by it: if its review is still there, what it chose isn't in that load, and the page says so instead of asking to reload.
- `reviews/model.ts` `settleable()` reads a review once, from its record and the entities it concerns (`FOUNDRY_CONCERNS`, read with the review): a `resolve` review (identity `type`, `doc_id`, `norm_name`), a `prop` review (`type`, `entity_key`, `prop`, `values`, and the entity's stored value), or `stale` when the identity the backend reads, or the entity, is missing (a load from before decisions: "Load again to settle this review"). Candidates are stored identities (a key's tagged encoding, such as `scus_…`), shown by the concerned entity's name and linked to its page.
- A property only a table row can hold through a conflict, so a `prop_conflict` whose entity has a stored value is a row conflict: its `values` list only the quotes that disagree with the table. The page says the table's value is kept and offers the documents' values as "Replace the table’s value with", warning that a decision holds in every later load, even after the table changes. It never offers to pin the table's value.
- An adjudicated resolution has an informational `FoundryReview` with reason `adjudicated`, its type, document, normalized name, chosen entity key and confidence; its only candidate is that entity. Treat it as a resolve review; "Undo link" saves `resolve: {notInData: true}`. Mentions marked `adjudicated` link to the document's review tab. Auto-adjudication stays off; the mock's opt-in `documentAdjudicated` fixture seeds a variant and its review for undo tests.
- Property-conflict `values` are the engine's JSON-encoded `PropQuote` records, including each value, document, span and quote. Parse them before offering a value; never send the whole quote as the value.
- The mock's settle route checks what the backend checks, with its codes (`mock/review-decisions.ts`): no current load and a review without identity, or whose type or prop the loaded version lacks, are `FAILED_PRECONDITION`; a chosen entity not in the load and a value the engine refuses are `INVALID_ARGUMENT`. A value is stored as the engine keeps it: strings trimmed (a blank one refused), dates `YYYY`, `YYYY-MM` or `YYYY-MM-DD`, datetimes in UTC (a UTC year outside 0000 to 9999 refused), `null` removes the value. Making a version current drops the decisions it doesn't take (an agent draft with types only), as the backend's set-current does; a newer draft alone changes nothing. Version 3's route lets documents state a customer's `currency`, which the customers table sets: " is billed in GBP." in a document makes a row conflict.
## Backend
`backend/` is the Go module `github.com/helixdb/helix-foundry/backend` ([backend/README.md](backend/README.md)): Foundry's API, its workers, its MySQL store and migrations. Its checks run in `backend/`: `go build ./...`, `go vet ./...`, `gofmt -l .` (prints nothing), `golangci-lint run ./...` (v1.62.2), and `go test ./...` with the test databases up (`docker compose -f compose.test.yaml up -d --wait`). After a `.proto` change, `buf generate && buf generate --template buf.internal.gen.yaml` regenerates `pb/gen`, which is committed. `.github/workflows/backend-check.yml` runs the checks and the regeneration on every change; `.github/workflows/backend.yml` runs the tests and builds the image (`docker build backend`) on changes to `backend/`.
- Hosted Helix imports this module and runs it inside its own server. REST paths, verbs and JSON shapes, SQL and transactions, migration versions, config keys, environment variable names, metric names and worker schedules are shared with that deployment: change them on purpose, never as a side effect.
- The module imports only its own packages, the standard library and public modules. What differs when hosted (sign-in and roles, workspaces, Helix databases, plans) comes in through the module's interfaces.
- Migrations are embedded from `store/migrations` (goose). Hosted runs them in one sequence with its own, so a new migration takes a version later than every existing one, and a shipped migration is never edited or renumbered.