eApps spec
Status: approved 2026-11-01 (Danny; decisions in §18). General availability: §20 (Milestone G, released 2026-11-03). Batch-1 readiness: §20.1. Sources: the code at
e59ef9f4909, epic #VAPP-110 (slice 1, done), #VAPP-175 (editor-first + eMCP),packages/emcp, eUI 0.3.0, the time-tracker check (thread 2db0b7d0), ExponentialOS plan v5 (thread 196b5162), Spezl #YO-32. Legend: ✅ exists · 🟡 partial · ❌ missing · 🆕 proposed here (no issue yet). The mocks are drawn with eUI (React harness, exponential theme, dark).
0. In one paragraph
Section titled “0. In one paragraph”An Exponential app is data, never code: a declarative package (manifest, eUI templates, bindings, record kinds, a function allowlist, exposes, upstreams) stored per team, versioned in the DB, and painted by the same eUI renderers on web, desktop, iOS and Android, as well as Spezl and eDE. Three executors run an app, and none of them runs app code:
- the client host, while someone is looking at the app;
- the server broker, for MCP calls (and, proposed here, the clock, events and webhooks);
- an agent, when the work is fuzzy.
Real code lives behind an upstream MCP server the team already runs. Hosted apps (#VAPP-101) stay cancelled.
The rest of this spec turns that rule into the missing pieces:
- records that know who owns them;
- a real query language;
- several screens per app;
- triggers;
- an offline outbox;
- the
eappscrate for Spezl and eDE.
1. Use cases → capabilities
Section titled “1. Use cases → capabilities”Each group lists concrete apps, then the capabilities they need. The capability IDs (C1…C30) are used in the roadmap (§17).
1.1 Team tools
Section titled “1.1 Team tools”| App | What it does | Needs |
|---|---|---|
| Hours (time tracker, an example) | Log hours per day with a note and issue refs; totals per day, month and member. Any app-specific rule (a target, a balance) is the app’s own records and expressions. | C1 ownership, C2 on-behalf backfill, C5 group-by member, C7 date maths, C13 spreadsheet import |
| CRM | Contacts, companies, deals in stages, last touch, follow-up dates. Log a call from Claude Code. | C3 relations, C8 multi-screen nav, C9 record forms, C16 exposes, C18 schedules (“follow up due”) |
| Inventory | Items, locations, stock moves, a low-stock alert. Scan a QR code on the phone. | C3, C6 sums of moves, C18 event trigger, C24 offline writes (warehouse without wifi), QrScanner ✅ |
| On-call | Weekly rotation, overrides, “who is on call now”, hand-off note. | C7 date maths (week of year), C18 schedule (Monday rotation), C19 agent hand-off (“summarise the incidents of the week”) |
| Approvals | Request → approver → approved/rejected, with a reason. Applies to expenses, leave, purchases. | C1 (only the approver decides), C4 per-kind policy, C10 state machine on a field, C18 event trigger (notify the approver), audit |
| Forms / surveys | A question set, anonymous or member answers, results as charts. | C21 public link (guest submit), C5 group-by answer, Chart ✅ |
| OKRs | Objectives → key results → check-ins, progress from linked issues. | C3, C11 computed fields (progress %), exp:issues ✅ |
| Standups | A daily three-question check-in per member and a team digest. | C18 schedule (09:00 reminder), C19 agent digest into a comment |
| Retros | Went well / to improve / actions; actions become issues. | exponential.issues.create ✅ (granted), C9 forms |
| Hiring pipeline | Candidates by stage, interview scorecards, files (CV). | C3, C4 (scorecards visible to the panel only), C20 files on records |
| Expenses | Receipt photo, amount, category, monthly total. | C20 files, C5, approvals |
| Leave / vacation | Requests, approval, remaining days per person, a team calendar. | C1, C5, C6 (days left = allowance − used), C7 date maths over the app’s own holiday records, a calendar view (C29) |
1.2 Personal tools
Section titled “1.2 Personal tools”| App | Needs |
|---|---|
| Habits (daily ticks, streaks) | C4 private kind (read: author), C6 streak = consecutive days, C24 offline |
| Notes | C4 private, Markdown ✅, search on records (C5) |
| Budget | C5 group-by category and month, C7, C13 bank CSV import |
Personal means private records inside a team app. In Spezl there is no team, so the same app is personal by construction (§16).
1.3 Ops and admin consoles over outside APIs
Section titled “1.3 Ops and admin consoles over outside APIs”Admin console (an example: a small software company’s back office) is one screen over several outside APIs:
- The company’s own MCP server: customers, plans and licences. The person signs in with their company account through OAuth.
- Stripe: MRR, failed payments, retry a charge.
- GitHub: open PRs and releases.
- Home Assistant: the office lights and heating.
- Exponential: file an issue “via Admin console”, ask an agent to follow up.
Each upstream is a team MCP server row, and every member connects their own account (✅ #VAPP-181/#VAPP-183).
Needs:
- C16
exposes; - C17 upstream reads inside templates (
upstream:binding source, ✅ #VAPP-230); - C17b upstreams reachable only on the LAN (Home Assistant), which needs a device-routed upstream (✅ M2, #VAPP-239: the server row names its owner’s device, §8);
- C22 a webhook (Stripe
invoice.payment_failed→ a record + a ping); - C19 agent hand-off.
1.4 Dashboards over Exponential data
Section titled “1.4 Dashboards over Exponential data”- Team pulse: issues opened and closed per week, cycle time, PRs merged.
- Sessions per device, agent cost.
- A device wall.
Needs:
- C5 aggregates over
exp:issuesandexp:sessions: group by status, assignee or week. Today those sources only list and filter. exp:devices✅ andexp:pullRequests❌.
1.5 Agent-facing apps (MCP tools for OpenClaw and Claude Code)
Section titled “1.5 Agent-facing apps (MCP tools for OpenClaw and Claude Code)”- “Log 2 h on VAPP-175” from Claude Code.
- “Which customers failed to pay?” from OpenClaw on the homelab.
This is exposes over /api/mcp/apps, with one OAuth flow (✅ #VAPP-180–185).
Needs:
- the same query power as the UI ✅ (C15, #VAPP-216: a tool’s
recordslist andrecords_listrun the Q1 query through the UI’s evaluator); - on-behalf rules (C2);
- descriptions frozen per version ✅.
1.6 Apps that trigger agents, scheduled and automated apps
Section titled “1.6 Apps that trigger agents, scheduled and automated apps”Examples:
- The standup digest at 17:00.
- “A new hiring candidate” → an agent drafts the interview plan.
- A Stripe failure → an agent writes to the customer.
- The on-call rotation flips every Monday.
Needs:
- C18 schedules and record events;
- C22 inbound webhooks;
- C19
run.agent, a prompt hand-off throughsessions.send; - C23 a run-as identity.
Nothing here may become a workflow engine: “standalone automations” and “workflows” are on the dead list (§7).
1.7 Apps elsewhere: Spezl offline, eDE widgets, phones
Section titled “1.7 Apps elsewhere: Spezl offline, eDE widgets, phones”- Spezl: the same package and the same records query, a local sqlite store, a loopback-only MCP (#YO-32), no server.
- eDE: an app as a desktop widget (a “today’s hours” tile, the on-call badge) and as a window. The desktop itself is eUI on disk (NEW-107).
- Phones: every app at 390 px, with a form factor that changes navigation (bottom bar instead of sidebar). Logging hours from the phone while offline in the train.
Needs: C24 offline outbox, C25 the eapps crate, C26 widget templates + slots (#VAPP-64), C27 form-factor conditions.
1.8 Sharing, templates, marketplace
Section titled “1.8 Sharing, templates, marketplace”- A public leave-request form.
- A read-only dashboard link for a customer.
- “Start from a template” in the gallery (✅ #VAPP-234: time tracker, CRM and standup; leave and on-call later).
- A signed catalog shared with Spezl (#YO-18).
Needs: C21 public links, C28 templates gallery, C30 signed catalog. Signing is already ✅ (#VAPP-161).
1.9 Import from spreadsheets
Section titled “1.9 Import from spreadsheets”Typical sources: a timesheet workbook (one sheet per month), a CRM export, a bank CSV.
Two paths:
- the agent path: the agent reads the file in a session, maps the columns, and writes on behalf of the right members;
- a mapping dialog.
Needs: C2, C13.
1.10 Capability index
Section titled “1.10 Capability index”| ID | Capability | Status | Issue |
|---|---|---|---|
| C1 | Record ownership: only the author or an owner edits or deletes | ✅ | #VAPP-187 |
| C2 | Writes on behalf of a member (owners), with audit | ✅ | #VAPP-188 |
| C3 | Relations between kinds and refs to issues and members | ✅ R5 (#VAPP-219) | R5 |
| C4 | Per-kind read/write policy (private, panel-only) | ❌ | 🆕 R3 |
| C5 | Group-by on any field, avg/min/max/count, text search | ✅ group-by + aggregates; 🟡 text search open |
#VAPP-190 |
| C6 | Running sums and totals before a date | ✅ | #VAPP-190 |
| C7 | mul/div/round, date maths (add, diff, startOf, weekday) | ✅ CEL subset, type-checked | #VAPP-191 |
| C8 | Several screens per app with navigation and deep links | ✅ U1 (#VAPP-218) | U1 |
| C9 | Forms and tables generated from a kind’s schema | 🟡 the Data tab does it in Exponential’s own UI (#VAPP-178); nothing in eUI | 🆕 U2 |
| C10 | Field state machine (allowed transitions) | ✅ R6 (#VAPP-222) | R6 |
| C11 | Computed fields on a kind | ❌ | 🆕 Q3 |
| C12 | CSV/xlsx export of a kind or the current query | ❌ out of scope (decided 2026-11-01): records leave through .app.json and MCP |
— |
| C13 | Spreadsheet import (agent path + mapping dialog) | ✅ the agent recipe (§10.2) and the mapping dialog ×4 (§10.3) | #VAPP-228 |
| C14 | Package export/import, signed | ✅ | #VAPP-121, #VAPP-161 |
| C15 | Broker records with the full query |
✅ Q4 (#VAPP-216) | |
| C16 | exposes (records / action / upstream) |
✅ | #VAPP-180, #VAPP-181 |
| C17 | upstream: read bindings in templates |
✅ #VAPP-230 (M1) | |
| C17b | Device-routed upstreams (stdio, LAN-only) | ✅ | M2 (#VAPP-239) |
| C18 | Schedules and record/event triggers | ❌ (agents only: agentTrigger) |
🆕 A1 |
| C19 | Agent hand-off (run.agent) |
✅ A2 (#VAPP-236) | ✅ A2 |
| C20 | Files on records | ✅ R7 (#VAPP-221) | format: "attachment" fields, POST /api/apps/{id}/files |
| C21 | Public / guest links (submit-only form, read-only view) | ❌ (#VAPP-24 cancelled, #YO-27 parked) | 🆕 P1 |
| C22 | Inbound webhooks | ✅ A3 server (#VAPP-237) + Settings ×4 (#VAPP-238) | ✅ |
| C23 | Run-as identity for unattended runs | ❌ | 🆕 A1 |
| C24 | Offline outbox for record writes | ✅ desktop, iOS, Android (#VAPP-235) | ✅ O1 |
| C25 | eapps crate (host, query, store, broker executor) |
🟡 the query + RecordStore live in eUI (S1 ✅ #VAPP-189); the crate itself is open (#EXP-1420) |
S2 |
| C26 | Widget templates + slots (eDE, home screen) | ❌ | #VAPP-64, NEW-107 |
| C27 | Form-factor / responsive templates | ✅ @media (form-factor: …) (U1), @media sizes, Sidebar.collapseBelow |
U1 |
| C28 | Template gallery | ✅ #VAPP-234: samples/apps/*.app.json, signed by the pinned Exponential key; “Start from a template” in the import dialog and a draft’s starters (fixtures/app-templates.json) |
Leave, On-call |
| C29 | Calendar / timeline view | ❌ (no component) | 🆕 U3 (styleguide first) |
| C30 | Marketplace (signed static catalog) | ❌ | #YO-18 |
2. Package model and versioning
Section titled “2. Package model and versioning”2.1 The package (✅, eUI 0.3.0 packages/exponential-ui/src/host/package.ts)
Section titled “2.1 The package (✅, eUI 0.3.0 packages/exponential-ui/src/host/package.ts)”{ "id": "acme.hours", "name": "Hours", "version": "2.0.0", "formatVersion": 2, // host reads ≤ 2; 3 = this spec "catalogId": "https://ui.exponential.at/catalogs/core/v1", "icon": { "glyph": "clock", "color": "blue" }, "description": "…", "sources": ["records:*", "exp:members", "exp:issues"], // allowlist "functions": ["records.*", "bind", "exponential.comments.create"], "records": { "entry": { "title": "…", "schema": { … } } }, // ≤ 16 kinds "templates": { "month": { "components": [ … ], "data": { … }, "bindings": [ … ] } }, "exposes": [ { "name": "log_hours", "access": "write", "input": { … }, "run": { "records": { … } } } ], "upstreams": ["stripe", "github"], "extensions": { "at.exponential": "^1" } // IssueRow, StatusGlyph, …}Caps (✅ contract.json apps): package ≤ 128 KB, ≤ 500 components per template, ≤ 16 kinds, record ≤ 16 KB, ≤ 32 exposed tools.
2.2 What formatVersion 3 adds (🆕)
Section titled “2.2 What formatVersion 3 adds (🆕)”| Key | Purpose | § |
|---|---|---|
screens |
✅ U1 (#VAPP-218): the navigation manifest [{template, title, icon?, params?}] (≤ 5), painted by the host as a sidebar on wide frames and the phone bottom bar on phones |
5.1 |
records.<kind>.policy |
✅ R3 (#VAPP-199): read/create/update/delete per kind: member · author · owners (role:<field> waits for R6 transitions) |
3.4 |
records.<kind>.refs |
Relations: {field: {kind}}, {field: "issue"}, {field: "member"} |
3.3 |
records.<kind>.computed |
✅ Q3 (#VAPP-214): expressions evaluated on read | 4.4 |
records.<kind>.transitions |
A state machine on one enum field | 3.3 |
queries |
Named structured queries, reusable by bindings and exposes (✅ Q1 for bindings, already in format 2) |
4.1 |
automations |
Schedule, record event, Exponential event and webhook → one run | 7 |
migrations |
Declarative record migrations between versions | 3.6 |
share |
Public link definitions | 9.4 |
A host refuses a newer formatVersion (✅ today), so old clients show “Update Exponential to open this app” instead of a broken screen.
2.3 Versions and channels
Section titled “2.3 Versions and channels”Today:
- ✅ Every
upsertwritesapp_versions.numberand becomes the head (the draft). - ✅ Restore makes a new head.
- ✅ The editor session id is recorded on the version.
- ✅ Draft vs published (V1, #VAPP-210/#VAPP-211, decided 2026-11-01; fixture
apps.jsonpublishCases×4):apps.packageis the PUBLISHED version’s package (published_version, 0 = never published);apps.draft_packageis the head’s while it is ahead.- Every version write (
apps upsert, Restore) makes a draft. Members, the exposed MCP tools (the broker), record policies and automations run the published package. - The editor’s preview shows the head while the viewer’s own editor session is live (or nothing is published), else the published version.
- “Publish” (the editor header; phones: the … menu; any Versions row; the viewing banner) or
exponential_apps {op: "publish", version?}moves the pointer. An older version = a rollback. An imported app, or one created with a package, publishes v1 at once. - Grants follow the published package: an owner’s publish keeps the still-declared grants, anyone else’s clears them.
- Records validate against the published kinds plus a draft’s new kinds (so an editor can seed them before publishing).
Proposed:
version(SemVer) is the author’s label.numberis the truth. A major bump with nomigrationsfor a changed kind is refused (§3.6). ✅ R8 (#VAPP-233): refused at upsert, naming the kind and the change (migrationRefusal,record-migrations.json).
3. Data: records
Section titled “3. Data: records”3.1 What exists
Section titled “3.1 What exists”app_records, keyed on (app_id, kind):
- columns
data jsonb,created_by,deleted_at,app_deleted_at; - one shape, member-only (web on demand per app, natives get the full shape);
- tRPC
apps.records.{list, upsert, delete}; - shallow patch inside
FOR UPDATE; - quota under an advisory lock: free 1,000 / team 25,000 / unlimited (
lib/plan-limits.ts); - a purge after retention (#VAPP-159).
Schemas are flat JSON Schema:
- types string, number, integer, boolean, array;
- formats date, date-time, time;
- enum, min/max, maxLength, required.
3.2 Identity of a record (✅ R1/R2)
Section titled “3.2 Identity of a record (✅ R1/R2)”| Column | Meaning |
|---|---|
created_by (✅) |
Who pressed the button. Never changes. |
author_id (✅ #VAPP-187) |
Whose record it is. It defaults to created_by. Only an owner may set it to someone else (an on-behalf write; ✅ #VAPP-188: author on records_upsert, .app.json import and the Data tab’s Author picker; the row reads “ |
updated_by (✅ #VAPP-187) |
The last writer. |
via_app_id / via_client_id 🆕 |
How the write arrived: UI, MCP client, automation, import. |
A per-member view groups by author, not created_by. That way a backfill an owner does for another member still counts as that member’s records.
3.3 Richer schemas (🆕 R5/R6, ✅ R7)
Section titled “3.3 Richer schemas (🆕 R5/R6, ✅ R7)”- refs (✅ R5, #VAPP-219):
records.<kind>.refs: {"customer": {"kind": "company"}, "task": "issue", "owner": "member"}on string fields.{kind}points to a record of that kind of the same app."issue"stores an identifier likeEXP-12."member"stores a user id.- Refs are validated on write (the target exists in the same app or team; else BAD_REQUEST naming the field) and resolved by queries (
expand). Cross-app refs are not allowed. Install checks the field is a declared string field and the kind exists. Deleting a target never cascades: the ref dangles and reads null when expanded.
- transitions (✅ R6, #VAPP-222):
{"field": "status", "initial"?: ["requested"], "from": {"requested": ["approved", "rejected"]}, "who": {"requested->approved": "role:approver", "paid": "owners"}}, enforced on the server (the records service: an undeclared edge = BAD_REQUEST naming it, a mover the edge’swhoexcludes = FORBIDDEN; creates start in an initial state; restores follow it unless an owner restores).whonames an edge or a target; levelsmember(default),author,owners,role:<field>(the member amemberref names before the write, or an owner). The checker validates the field is an enum and every state exists. Rows of the kind carrytransitions(the viewer’s legal next states) so a template offers only those;record-transitions.json, TS + Rust, replayed ×4. - files ✅ (R7, #VAPP-221):
{"type": "string", "format": "attachment"}holds one file, a list of strings with"items": {"type": "string", "format": "attachment"}several. The value is/api/attachments/<id>(relative, lowercase uuid; eUIvalidateRecord×2 twins,host-router.jsonacme.files).- Upload: a
FileUploadwhosefilesis bound; the app host posts each picked file toPOST /api/apps/{appId}/files(write access to the app; the attachments pipeline: caps, the team’s storage budget) and writes the returnedurlinto the entry of the same name at the bound path. An action stores the urls in the record. - The row is the app’s (
attachments.app_id, no other owner, never synced). The bytes route serves it to team members whose access to the app is notnone; the app hosts’ media rule (appMediaAllowed) loads it inImage. - On write the records service checks that every referenced file is a row of the record’s own app and team (
attachments must be files uploaded to this app), before theeappsrole. - Purge: the attachment sweep reclaims an app file that no record of its app references, past the week-long orphan grace. A trashed record still references its files, so they live until the trash purge (30 days) drops the record. An app’s hard delete hands its files to the orphan sweep.
- Upload: a
- Nesting stays out. Nested objects make every client’s derivation harder. Use a second kind plus a ref.
3.4 Permissions
Section titled “3.4 Permissions”Layers, all intersected:
- Team membership ✅. Only members, unless a public link applies (§9.4).
- Per-app member policy
app_accessnone/read/write. ✅ (#VAPP-199) applied everywhere: MCP, the records service (none = NOT_FOUND, read = writes FORBIDDEN), the apps router, the apps / app-versions / app-records shapes (none = the app and its rows never sync), the RLS scope (eapps.write), and the clients (appAccess.mine; the Data tab disables writes). An app’s own writing controls paint disabled at read (#VAPP-200, the eUIreadOnlyhost option ×7 renderers). - Per-kind policy ✅ (R3, #VAPP-199,
packages/exponential-ui/fixtures/record-policy.json, TShost/record-policy.ts+ Rusthost::record_policy, iOS/Android hand ports replaying the fixture):
"policy": { "read": "member", // member | author | owners (owners = not even the author) "create": "member", "update": "author", // the author or a team owner "delete": "author"}The defaults are member/member/author/author. This is the common case; it ships with formatVersion 3, and as eApps keeps no backwards compatibility (decided 2026-11-02) v2 packages are not read after that.
The server enforces the policy (the records service on every read and write, the broker’s reads, the import’s creates; a row the member may not read is NOT_FOUND). The app-records shape enforces read: author / read: owners with a row filter for non-owners (lib/apps/records-shape.ts): a private kind never syncs to other members’ devices. create is never author. Package tests take role and run under the policies. ✅ R6 (#VAPP-222): role:<field> levels live on a kind’s transitions.who (the member a member ref names), not on policy.
- Field-level privacy is out. Use a separate kind with
read: owners(for example, salary next to a hiring candidate). - The Postgres scope ✅ (#VAPP-193), a backstop under layers 1 to 3, not a replacement. Every server read and write of a record runs through ONE service (
apps/web/src/lib/apps/records-service.ts), which checks layers 1 to 3, ownership and quota, then runs the record statements as theNOLOGINroleeappsscoped to one team and its apps (§12.2). Ownership and policy stay in the service, never in RLS.
3.5 History, undo, retention, quotas
Section titled “3.5 History, undo, retention, quotas”- ✅ History (R4, #VAPP-215, fixture
app-records.jsonhistory×4): anapp_record_eventsrow per records-service write, in the write’s own transaction under roleeapps(RLS team + app):{record_id, op (create/update/delete/restore), actor, author, via (editor/app/mcp/action/import), patch, before}. It is kept 90 days, likemcp_calls(the watchdog purge); a record’s hard delete takes its events. Read on demand (no shape): tRPCapps.records.history(the record’s read policy), MCPapps records_history/records_restore. - ✅ Undo: a Data tab write and a package action’s batch toast Undo for 10 s (the existing toast action); Undo = each event’s
beforeas a new write (apps.records.undo: a create undone = a delete, a delete undone = a restore). The record form’s History section (existing list rows: label, who · when) restores any earlier state as a new write (apps.records.restore, needs update). - Deletes are soft ✅ (
deleted_at). The trash is purged after 30 days (✅ purge, #VAPP-159). Owners can restore from the Data tab. - 🆕 Retention per kind:
"retention": "365d"purges old rows. Retention is not a quota tool. - Quotas ✅ per team and plan. The editor and Data tab show the count against the plan. 🆕 A soft warning at 80 %. A write over the quota fails with a toast, never silently.
3.6 Migrations (🆕 R8, #VAPP-233)
Section titled “3.6 Migrations (🆕 R8, #VAPP-233)”✅ (R8 1/3) The package side: migrations: [{from: <range>, kind, ops: [rename {from, to} | default {field, value} | drop {field} | map {field, expr} | renameKind <kind>]}], migrateRecord / validateMigrations / kindChanges / migrationRefusal (TS host/record-migrations.ts + Rust host::record_migrations, fixtures/record-migrations.json), checked at install, and the major-bump refusal at upsert. ✅ (R8 2/3) The sweep on publish: records carry schema_version (every write stamps the published number); a publish of a version with migrations sweeps the app’s records in batches (records service + RLS, all or nothing per batch, R4 events via migration, resumable through the watchdog, progress migration_done/migration_total/migration_state on the version row, a failing record stops it with its error). ✅ (R8 3/3) The clients: the Versions row shows the sweep (Migrating records 120 of 500, Records not migrated: …; apps.json migrationCases ×4, after the row’s subtitle, no new component) off the synced app_versions.migration_* columns; the editor previews a draft that declares migrations on migrated copies of the app’s records (from the published version; nothing written; FFI migrate_record_json, SDKs RecordMigrations). Lazy reads through the chain are not built: the sweep is eager and a record reads in its stamped shape until it moves. The ops below are the original sketch; set/cast became default/map.
Records carry schema_version (the package number that wrote them). A new version that changes a kind declares its migrations:
"migrations": [{ "kind": "entry", "ops": [ { "rename": { "from": "issues", "to": "refs" } }, { "set": { "field": "project", "default": "Exponential" } }, { "drop": "legacy" }, { "cast": { "field": "hours", "to": "number" } }]}]- The server applies them lazily on read and on write, and eagerly in a background sweep, using compare-and-swap like the vault sweep.
- An additive change (a new optional field) needs none.
- A change that loses data is refused unless an op covers it.
- Every client reads through the same migration function (TS + eui-core) until the sweep finishes.
4. Query and expression language
Section titled “4. Query and expression language”4.1 The structured query (✅ Q1, #VAPP-190)
Section titled “4.1 The structured query (✅ Q1, #VAPP-190)”Today a binding is a URL (records:entry?mine=1&by=date&sum=hours). URLs stay as sugar. The canonical form is JSON, named in queries and referenced by bindings, exposes and automations:
"queries": { "month": { "from": "entry", "where": [["date", "between", "${month.start}", "${month.end}"]], "group": ["author", { "bucket": "date", "by": "day" }], "aggregate": { "hours": ["sum", "hours"], "n": ["count"] }, "sort": [["date", "desc"]], "limit": 500 }, "before": { // totals before this month "from": "entry", "where": [["date", "<", "${month.start}"]], "group": ["author"], "aggregate": { "total": ["sum", "hours"] } }}| Feature | Today | Spec |
|---|---|---|
| Filters | ✅ equality, .gt/.gte/.lt/.lte, mine, user, app=; ✅ Q1 in, between, one {or: [[…]…]} (also <, >=… in JSON) |
+ contains, startsWith, isNull, text search |
| Sort / limit | ✅ (limit ≤ 500); ✅ Q1 several sort keys (JSON) | + a cursor |
| Group-by | ✅ Q1 any field, author, time buckets day/week/month/quarter/year (ISO weeks), plus by=<date>&month= |
Buckets in the team timezone (today: the stored calendar day; a timestamp’s UTC day) |
| Aggregates | ✅ Q1 sum avg min max count countDistinct first last (per group + totals) |
|
| Running values | ✅ Q1 running: {field: "sum"} (sum count avg min max, over groups, rows or month days) and before: <date> (totals up to a date, seeding the running values) |
streaks |
| Joins | ✅ R5 (#VAPP-219) expand: ["customer"] / expand=customer follows refs (one level): a record row, an issue summary {id, identifier, title, status}, a member {id, name}; dangling = null (TS + Rust, records-query.json ×4; the server’s Q4 reads too) |
There are no free joins. |
| Sources | records:<kind>, exp:issues, exp:boards, exp:members, exp:sessions, exp:comments, exp:attachments, exp:devices, ✅ upstream:<server>/<tool> (§6.3, the tool’s answer, no query) |
The same query shape over every exp: source (group issues by status or week), + exp:pullRequests |
One evaluator:
- ✅ (Q1, #VAPP-190) The JSON form:
parseRecordsJson(json, kind?)→{query, issues}(Rustparse_records_json, FFIparse_records_json, SDKsRecordsQuery.parse(json:)/parseJson), theq=<json>param, and the package’squeriesfollowed by?query=<name>&<params>(the router expands it,${param}templates filled from the binding’s params). The value gainsgroups,totals,beforeandrunning. The date helpers (dateBucket,parseDay,isoWeek,daysInMonth,weekdayOf) live insrc/dates.ts/eui_core::datesfor Q2.queriesis optional, likeexposes(format version 3 since R3). The spec’s"${month.start}"is spelled"${start}"(flat binding params). - ✅ (S1, #VAPP-189) A pure TS reference (
@exponential_app/eui/host/query) and its Rust twin ineui-core(host::query), fixture-locked (fixtures/records-query.json, replayed in TS, Rust, Swift and Kotlin). The URL params parse INTO a structuredRecordsQuery(from, app, mine, user, where, sort[], limit, sum, month); the JSON form (Q1) adds keys next to these. - Web and the server broker use TS. Desktop, iOS, Android, Spezl and eDE use eui-core (iOS and Android already link it through UniFFI for painting).
- ✅ The four hand-written derivations (
AppRecords.swift,AppRecords.kt,app_records.rs,app-records.ts) are gone; their Data tab, ownership and on-behalf helpers stay. - Queries run locally over the synced store. A query over more rows than the device holds (web on-demand) asks the server, which uses the same evaluator over SQL.
4.2 Expressions (✅ Q2, #VAPP-191)
Section titled “4.2 Expressions (✅ Q2, #VAPP-191)”The bind-time functions exist ✅: add, sub, percent, eq, lt, clamp, cond, fallback, concat, coalesce, text, map, len, fill, filter, and/or/not, plus the format functions.
To add, both as functions ({"call": "mul", …}) and as the compact string form used in compute/computed:
| Group | Functions |
|---|---|
| Arithmetic | mul div mod round floor ceil min max abs |
| Arrays | sumOf avgOf minOf maxOf countOf groupOf sortBy over a bound list |
| Dates (team timezone) | today now dateAdd(d, n, unit) dateDiff(a, b, unit) startOf(d, unit) endOf weekday isoWeek |
| Durations | hours(minutes), formatDuration(h, "h:mm") |
| Text | lower upper trim split join matches |
The string form is a tiny pure grammar (numbers, paths, operators + - * / % < <= == && || !, calls). It is parsed once at package validation, has no loops and no user functions, and must terminate. The function form stays the A2UI wire form, and the string form compiles to it.
- ✅ (Q2, #VAPP-191) The string form is a CEL subset, hand-written as TS (
src/cel.ts) + Rust (eui_core::cel) twins replaying ONE fixture (fixtures/expressions.json; FFIevaluate_expression_json/check_expression_json, SDKsExpressions.evaluate/check(Swift;Expressionon Compose)). cel-js / thecelcrates were not used: CEL splits int/uint/double (JSON has one number), their error texts and date handling would differ between the twins, and they would bloat the eUI bundle. One number type (7 / 2= 3.5); a missing field reads null. It is used as{"call": "expr", "args": {"expr": "…", "vars": {…}}}anywhere a value may be bound, and the arithmetic, date and duration rows above are also plain A2UI functions ({"call": "mul", …}) running the same table. Everyexpris type-checked byvalidatePackageagainst its vars (a literal, a records binding’s kind schema, the template data), issues at the JSON pointer. Caps: 1000 characters, nesting 32, no nested comprehensions, 10 000 steps. The team zone is the zone the host passes (HostZone): ✅ #VAPP-201teams.time_zone(Settings › General, owners; fixtureteam-zone.json×4), else the device’s zone. Hardened by the CEL differential fuzz (fixtures/cel-fuzz.json, TS vs Rust, in CI): it found and fixeddateAddoverflowing on a hugenin both twins. The Arrays and Text rows stay open.
Missing values are explicit (✅ E2, #VAPP-195): a field that is not in its kind’s required, and a var the checker cannot type, is typed T?. Arithmetic, comparisons and function arguments on a T? are install issues unless guarded: has(r.f), r.f == null ? … : …, or orElse(x, default). Every binding carries a fallback ({"call": "expr", "args": {…, "default"}}, shown when evaluation fails), so a renderer never paints undefined and never throws on a binding. Built (E2): T? is spelled {"?": T}; an unguarded use names its path (entries.rows[0].hours may be missing: guard it with has(), a null check or orElse()); has(), != null / == null (through &&, ||, !) and orElse narrow; default is required on every package expr binding and every bind-time call falls back to its default (TS dynamic.ts, Rust data.rs: all six renderers); fixture expressions.json, replayed ×4.
4.3 Time
Section titled “4.3 Time”- Everything date-like is evaluated in the team timezone.
dateis a calendar day, not an instant. - Weeks are ISO and months are calendar months.
- The platform has no notion of working days, holidays or targets. An app that needs them keeps them as its own records (a
holidaykind, atargetkind) and combines them withweekday,dateDiffand the query.
4.4 Computed fields (✅ Q3, #VAPP-214)
Section titled “4.4 Computed fields (✅ Q3, #VAPP-214)”"computed": { "minutes": "round(hours * 60)", "week": "isoWeek(date)" }Computed fields are evaluated on read by the same evaluator. They are never stored, so they never go stale. Queries can filter, sort and group on them.
- ✅ (Q3, #VAPP-214) Built:
records.<kind>.computed(≤ 16 per kind). Each expression reads the record’s schema fields and the row keys (id createdAt updatedAt createdBy authorId) by name; a failing one reads null. The records query writes them into every row of the kind before it runs (recordsValue(…, {computed}), TSwithComputed/ Rustcomputed_data, FFI contextcomputed, SDKsRecordsQuery.value(…, computed:)+RecordsQuery.computed(records:kind:)), so filters, sorts, groups, aggregates, running values,sum=and the month view read them, and a template’s row type carries them (typed). The hosts ×4 pass the hosting package’s; another app’s rows (app=) carry none. The server’s records service applies them toapps.records.listand every Q4 read (readAppRecordRows: the broker’srecordsruns and MCPrecords_listwithqueryfilter, sort and group on them). Checked at install (/records/<kind>/computed/<name>): typed against the schema (E2T?), no shadowing a schema field or a row key, no computed field reading another (no chains, no cycles), the cap. Evaluated in UTC with the query’s clock, like the query’s own buckets (the team zone lands with the buckets’). Fixtures:records-query.json(values, filter, sort, group, aggregate, the month view) andexpressions.jsonpackages, replayed ×4.
4.5 Effects: pure bindings, all-or-nothing actions (✅ E1, #VAPP-194)
Section titled “4.5 Effects: pure bindings, all-or-nothing actions (✅ E1, #VAPP-194)”One language at two levels, on the Q2 core:
- Expressions are pure. Bindings,
visible, computed fields and query params cannot write. Every function in the signature table carries an effect (pure | write | host), and the checker refuses an effectful one in a pure place at install (path + reason). - Actions (
actions: {<name>: {params, steps}}, named by anon.press, anexposesrun or an automation) are ordered steps:require <expr>,records.upsert,records.delete,set <pointer> = <expr>(the surface data model), and host functions fromfunctions(behind the same policy and consent as today). - All or nothing. The steps evaluate against one snapshot. A false
require(its message is the toast), the action step limit (100,000), an error, or a write refused by policy or schema discards the whole action. Record writes land as one unit: one Postgres transaction in the records service, oneRecordStorebatch on a client host, one outbox entry offline (O1). - An action runs where it was triggered: the client host for a press, the server broker for
exposesand automations. The evaluator is the same ×4; fixtureactions.json. - The authority stays the server (§3.4, §14):
requireguards a single write path. Cross-peer invariants are not promised on P2P stores (Spezl, §16). - ✅ (E1, #VAPP-194) Built as
src/host/actions.ts+ the effect table insrc/cel.ts(Rusteui_core::host::actions/cel, FFIrun_package_action_json,apply_record_batch_json,function_effect; SDKsActions), fixturefixtures/actions.jsonreplayed in TS, Rust, Swift and Kotlin. Step formsrequire(+message),records.upsert,records.delete,set,call; CEL readsparams,dataandme; a press callsactions.<name>. Every host (ExponentialHost×4) gates and asks for every host call first, then commits the writes throughrecords.batch(Exponential: tRPCapps.records.batch= one transaction in the records service, every write re-checked), then applies the sets, then runs the calls; a failure =ACTION_FAILED+ the failed-action toast. Open: the broker’sexposesrun.actionform and automations (A1). ✅ The offline outbox entry (O1, #VAPP-235): a batch is one outbox entry on desktop, iOS and Android.
5.1 Screens and navigation (✅ U1, #VAPP-218)
Section titled “5.1 Screens and navigation (✅ U1, #VAPP-218)”screenslists the templates a person can open (1..5, keyed by the template;title, an eUIicon, defaultparams). The host draws the shell from it with Exponential’s EXISTING navigation: the sidebar row pattern on wide frames (desktop, web ≥ md, the preview’s Desktop frame) and the phone bottom tab bar on phones (and the preview’s Phone frame). No new component.- The app draws no chrome of its own for this, so every app navigates the same way.
- The host function
app.navigate {screen, params?}(a press, or an action’scallstep after its writes; always allowed) and the route/t/$team/apps/$slug/$screen?…give deep links that work ×4 (natives: the same path as an app link). paramsbecome/params/*in the template data, and a binding source’s${params.month}is filled (percent-encoded) when the screen opens.- Install refuses an unknown template, a duplicate, a sixth screen, a bad icon, a non-string param and a literal navigate target that is no screen.
- ✅ Built as
src/host/screens.ts(validatePackageScreens,resolveScreen,navigateTarget,fillScreenParams) + Rusteui_core::host::screens(FFIresolve_screen_json,navigate_target_json; SDKsScreens,HostOptions.navigate×4), fixturefixtures/host-router.json(screens,navigate,acme.badscreens), the host contractcatalog/host.jsonpackage.screens. The editor preview paints the screens in its frame (apps.jsonscreensCases); with screens it replaces the template picker.
5.2 Templates and components
Section titled “5.2 Templates and components”- An app may use only catalog components (91 ✅,
core.catalog.json) plus Exponential’s extension (IssueRow, SessionRow, StatusGlyph…, ✅). - No new component enters an app unless it is first in the styleguide and, if it is low-level, in eUI. That is the team rule; app authors (agents) can’t invent widgets.
| Need | Status |
|---|---|
| Tables (sort, select, sticky header, density) | ✅ Table |
| Inline cell edit in tables | ❌. Proposal: none; edit opens the record form in a Drawer. |
| Charts (bar, stacked, line, area, pie, donut, sparkline) | ✅ Chart, StatTile, Meter, Ring, Sparkline |
| Forms and validation | ✅ Form, FieldGroup, FormActions, checks (required, email, length, regex, numeric), validateOn |
| Form from a kind’s schema | 🆕 U2: RecordForm {kind, id?} as an eUI macro expanding to Form + inputs (date → DatePicker, enum → Select, ref member → Select over exp:members, string[] of issues → ChipInput). The server re-validates the kind’s schema. |
| Table from a query | 🆕 U2: RecordTable {query, columns?} macro. Columns come from the schema, the row press opens RecordForm in a Drawer. |
| Calendar / timeline (leave, on-call) | ❌ 🆕 U3: styleguide first, then eUI (Calendar month grid + Timeline). It is low-level, so it belongs in eUI. |
| Empty, loading and error states | ✅ EmptyState, Skeleton, toasts per feedback-states.json |
5.3 Responsive
Section titled “5.3 Responsive”- ✅ Style
@mediaconditions with breakpoint tokens, responsive props (columns: {base: 2, md: 4}),Sidebar.collapseBelow. - ✅ U1 adds the
@media (form-factor: phone | wide)style condition: the host’s form factor (the preview frame, a phone), unset = the surface width against$breakpoint.md. Use it only when a layout truly differs (the time tracker’s phone screen is a list of days, not a table).tablet/widgetand ascreens[].phonetemplate override wait for a need.
The two Hours mocks above are the same package. The phone picks the month.phone override: Rows per day and one wide primary button.
6. Actions and functions
Section titled “6. Actions and functions”6.1 The three families (✅)
Section titled “6.1 The three families (✅)”| Family | What | Consent |
|---|---|---|
harness.* |
toast, openIssue, openDevice, records.upsert/delete, mcp (always asks) | None for records; harness.mcp asks every time |
exponential.<noun>.<op> |
84 typed actions, each an MCP call as the viewer | Only an owner grants (apps.granted_tools). Destructive and owner-only ops are never grantable. A non-owner version clears all grants. |
bind, set, openUrl |
Re-bind a source, set data, open a link (URL policy: private hosts denied) | None |
6.2 Side effects
Section titled “6.2 Side effects”The complete list: records writes, MCP operations, toasts, navigation and links. There is no fetch, no background code and no package tools (refused by Exponential’s validator ✅).
🆕 Add navigate (§5.1). ✅ handoff (§7.3, A2): the exponential.apps.handoff action.
6.3 Upstreams in the UI (✅ M1, #VAPP-230)
Section titled “6.3 Upstreams in the UI (✅ M1, #VAPP-230)”harness.mcpasks every time: fine for a rare action, useless for a dashboard. So a template has a read binding sourceupstream:<server>/<tool>?<args>(@exp/emcpupstream.jsonbinding, its$commentthe spec):- the name splits at its last
/; each query param is one argument, read as JSON when it is a number,true/false/null, an object or an array, else the string (so abindaction’s params drive it); - the server must be in the package’s
upstreams(an install issue otherwise) and the source in itssources(upstream:<server>/<tool>,upstream:<server>/*orupstream:*); - read-only only, checked at every call against the tool’s live annotations:
readOnlyHint: truereads; a tool with no hint reads only whensourceslists it exactly (the author vouches);readOnlyHint: falseand destructive tools never run; - run by the server broker as the viewer with their own credential (
resolveForLaunch), exactly one tool per read, never another app’s upstreams,_metaprovenance on the answer (§12.1); - cached for 60 s per (member, server, tool, args); errors are never cached, and hosts re-read every 60 s while bound;
- the value = the result’s
structuredContent, else its text as JSON, else the text.
- the name splits at its last
- ✅ M2 (#VAPP-239): a server that names a device reads ON that device for its owner (§8 “Device-routed upstreams”), same rules, same cache; anyone else’s read is refused
deviceNotOwned, and an offline device answers{ok: false, error: upstreamFailed, server, offline: <device>}. - Hosts ×4 subscribe through one tRPC query,
apps.upstreamRead {id, source}. A refusal or offline keeps the path’s template data (the binding’s default); a viewer not connected to the server gets one host banner per server, “Connect”, opening Settings › MCP. - Reads are not written to
mcp_calls(a dashboard re-reads every minute); exposes calls still are. - Writes stay
exposes-style named actions ({"call": "upstream.stripe.retry_charge"}). They are listed infunctions, granted by an owner, and confirmed by the viewer the first time.
7. Automation
Section titled “7. Automation”Rule: one trigger runs one binding. No chains, no branches, no loops. Anything with judgement is handed to an agent. This keeps clear of the dead “standalone automations / workflows” (decided 2026-11-01).
7.1 Triggers
Section titled “7.1 Triggers”✅ A1 (#VAPP-220): automations, one trigger → one binding, run by the server as a sponsor.
"automations": [ { "name": "monday_rotation", "on": { "schedule": { "cron": "0 8 * * MON" } }, "run": { "records": { "op": "upsert", "kind": "rotation", "data": { "startedAt": "${firedAt}" } } } }, { "name": "notify_approver", "on": { "record": { "kind": "request", "op": "create" } }, "run": { "action": "exponential.comments.create", "args": { "issueId": "${record.issue}", "body": "New request: ${record.title}" } } }, { "name": "log_merges", "on": { "event": "pr.merged" }, "sponsor": "lead@acme.test", "run": { "records": { "op": "upsert", "kind": "merge", "data": { "title": "${event.title}" } } } }]The contract is packages/emcp/fixtures/automations.json (TS @exp/emcp/automations, Rust emcp::contract::automations); the server rules and the executor are apps/web/src/lib/apps/{package,automations}.ts.
on(exactly one):schedule: {cron, tz?}: a 5-field cron (the agents’ trigger cron: names, ranges, steps), read intzor the team time zone; the minutes of a day it fires on are ≥ 15 min apart. The watchdog tick runs the latest due minute since its last check, caught up once after downtime (24 h), never twice.record: {kind, op}: a committed create/update/delete of a declared kind (members’ writes, package actions, tools).event: "<name>": the team stream (issue.created,pr.merged,session.ended… or anyevents_emitname), the same events agents’ triggers use ✅. It runs once the event is committed.webhook: {name}(A3, §7.2): a signedPOSTto the app’s hook URL; the body is${event.body.<path>}.wherefilters are not in A1.
run: anexposesrun (records,action,upstream,agent) with the same validator: no destructive runs, an action must be granted by an owner, an upstream must be declared.agent(A2) hands off to a team agent (§7.3). Templates:${record.<field>}(record triggers: its data +id; on delete, the data it had),${event.<field>}(event triggers: the payload +name),${firedAt}. No action throughexponential.apps.*.- Executor: the server broker (
runBrokerTool, the same records service, grants and vault as a tool call), on the published version only (V1). Every run, and every refusal, is onemcp_callsrow withclient_id = automation:<name>,tool=records.<op>:<kind>, the action, or<server>/<tool>(Settings › MCP › Activity). - Run-as (C23): an automation acts as its sponsor: the member who published the running version (
apps.published_by), or a member it names by email (sponsor), honoured only when an owner published. The sponsor must still be a member with write access to the app. Upstream runs use the sponsor’s credential; writes read “via” (§9.3). Otherwise the automation pauses ( sponsorGone,sponsorReadOnly,sponsorNotAllowed), the app’s owners get one notification per pause, and a publish by a member with access resumes it. There is no bot identity (decided 2026-11-01). - Caps: at most 16 automations per app; at most 30 runs a minute per app (over it: audited
rateLimited, not run). No chaining: a run executes in an automation scope, so its records write and the events its action emits fire no automation, its own included. - Settings: the app’s settings page lists each automation with its trigger, its last run and its last failure (from its audit rows) and a pause, ×4.
7.2 Webhooks (✅ A3 server, #VAPP-237)
Section titled “7.2 Webhooks (✅ A3 server, #VAPP-237)”on: {webhook: {name}} runs an automation when an outside service posts to the hook. The contract is packages/emcp/fixtures/automations.json (webhook, TS @exp/emcp/webhooks); the server half is apps/web/src/lib/apps/webhooks.ts.
{ "name": "stripe_paid", "on": { "webhook": { "name": "stripe" } }, "run": { "records": { "op": "upsert", "kind": "payment", "data": { "customer": "${event.body.data.customer}" } } } }- URL:
POST /api/apps/hooks/<appId>/<name>. The hook name is unique among the app’s webhooks; the published package’s automations on it run (A1’s executor: the sponsor, the caps, the audit). - Secret: one per hook,
whsec_+ 32 random bytes (base64url), minted by a team owner on the app’s Settings (tRPCappAccess.rotateHook), answered ONCE, stored only vault-sealed (ev1, slotapp_hooks.secret_ciphertext, re-sealed by the vault sweep), never synced. Minting again rotates it: the old secret stops at once. A hook without a secret answers 404. - Verification: header
exponential-signature: t=<unix seconds>,v1=<hex HMAC-SHA256(secret, "<t>." + raw body)>(Stripe’s scheme; severalv1may ride along). In order: body > 64 KB = 413, no header = 401, malformed = 401,|now − t|> 300 s = 401 (stale), no matchingv1= 401, the signature already seen (the nonce cache: every accepted signature for 2 × the window, tableapp_hook_nonces, pruned by the watchdog) = 409 (replayed), not JSON = 400. The body is parsed only after the signature verifies. A verified delivery answers 202 with each run’s status. - Payload: the run’s event =
{hook, body}:${event.body.<path>}in the run (and in an agent hand-off’s data block, never its instructions, §12.1). - Rate and audit: at most 60 deliveries per hook per minute (429 beyond, not audited, so a flood never fills the audit table). Every delivery under the cap is one
mcp_callsrow: the run’s (automation:<name>), or a refusal auditeddeniedwith its code (badSignature,stale,replayed, …) as the publisher. - ✅ Settings ×4 (#VAPP-238,
apps.jsonwebhookRowCases): the Webhooks group under Automations, one row per hook with its URL (Copy URL), a Ready / No secret pill and the owner’s Create secret / Rotate secret (therotate-webhook-secretprompt first); the answer’s secret is shown once under its hook (Copy secret), held in memory only. The trigger line “On webhook” is ✅ ×4.
7.3 Agent hand-off (✅ A2, #VAPP-236)
Section titled “7.3 Agent hand-off (✅ A2, #VAPP-236)”run.agent hands judgement to a team agent. The contract is packages/emcp/fixtures/exposes.json (agent, $comment the spec) and automations.json (the agent loop guard); the server half is apps/web/src/lib/apps/agent-handoff.ts.
{ "name": "route_request", "on": { "record": { "kind": "request", "op": "create" } }, "run": { "agent": "triage-bot", "prompt": "Route the request ${record.title} to its owner.", "device": "default" } }- The form:
{agent, prompt, device?}.agent= the agent’s slug (its name lower-cased, everything outsidea-z0-9one-: “Triage Bot” =triage-bot; the team’s oldest agent with that slug, never a personal Assistant).prompt= at most 4000 characters, frozen per version.deviceabsent or"default"(the agent’s own device). It always writes. - Where it runs: an automation (A1, as its sponsor), an
exposestool (as the calling member) and thehandoffactionexponential.apps.handoff {slug, tool, input, record?}(a press runs one of the package’s publishedagentexposes as the viewer, behind the consent card unless an owner granted it; display rowapps_handoff). - The session:
sessions.send {agentId}, the one entrypoint: the run-as member’s own conversation with that agent (created on first use on the agent’s device, then resumed), reading “via” (M3 via_app_id). The agent’s output lands in that conversation; it calls back into the app only through the app’sexposes, if its MCP grant includes the app. - The brief (data is never instructions, §12.1): the prompt with each
${…}filled as inline data (backticks, quotes stripped of backticks and newlines, at most 200 characters each), thenFrom the app <name> (<automation|tool|handoff> <name>)., for a record triggerRecord: <kind> <id> of the app <slug>.(the ref back to the record), and the whole input as a fenced JSON block marked as data (at most 8000 characters). - Caps: at most 10 hand-offs per app per rolling minute (over it:
rateLimited, audited), beside the automations’ 30 runs. Loop guard: a record write or an event caused from inside an agent’s session tree (the MCP gate tags each call with the caller’s own session) never fires an automation that hands off to that same agent; every other automation still runs. - Audit: an automation’s row reads
tool = agent:<slug>; an exposed tool’s row is the broker’s as before.
8. MCP
Section titled “8. MCP”| Piece | Status | Where |
|---|---|---|
| Contract (exposes, access, audit, vault, upstream) | ✅ | packages/emcp (#EXP-1490) |
| Server half: one gate (token → member → grant → access → audit) | ✅ | apps/web/src/lib/emcp (#EXP-1491) |
| Rust crate (loopback server, client, OAuth, vault trait, contract replay) | ✅ (rules only, no executor) | apps/desktop/crates/emcp (#EXP-1492) |
exposes in the SDK |
✅ | eUI 0.3.0 (#VAPP-180) |
/api/mcp/apps broker, <app>__<tool>, _meta provenance, list_changed |
✅ | apps-broker.ts (#VAPP-181) |
| Consent (team, apps, Read only / Full, inline Connect) | ✅ | #VAPP-182 |
app_access + per-tool switch |
✅ | #VAPP-183 |
| Revocation cascades | ✅ | #VAPP-184 |
| Settings › MCP merged ×4 | ✅ | #VAPP-185 |
| “via |
🟡 comments only | #VAPP-186 |
Broker records = full query |
✅ | Q4 #VAPP-216: run.records list + records_list query run the Q1 evaluator (@exponential_app/eui/host/query) over the caller’s readable rows (read policy + RLS first, ≤ 25,000 rows read); records-query.json replays through the broker |
| http upstreams | ✅ | |
| stdio / LAN upstreams | ✅ M2 (#VAPP-239) | @exp/emcp upstream.json device (its $comment is the spec), lib/emcp/upstream/device-call.ts, the relay’s POST /devices/:userId/:deviceId/upstream, steer::upstream_call + emcp::device_call on the device (below) |
| eMCP local hub + codemode | in review | #EXP-1507 |
| Spezl loopback broker | ❌ | #YO-32 |
Device-routed upstreams (✅ M2, #VAPP-239): a team server row may name a device (mcp_servers.device_id: a desktop app or CLI daemon of a team OWNER, set with mcpServers.setDevice, only to a device of their own; a removed device clears it). It then runs THERE for app calls: a stdio command, or an http URL only that device’s network reaches (plain http:// is allowed on loopback and LAN hosts: private IPv4, .local, .lan, .home.arpa, .internal).
- Who: only the device owner’s own calls (exposes runs, M1 read bindings) and automations they sponsor route to it; anyone else’s are refused
deviceNotOwned(auditeddenied), so nobody spends a teammate’s machine. A stdio server naming no device staysupstreamFailed. - The path: the broker writes one
mcp_device_callsrow (tool, args, the veto, a fresh X25519 reply key; 30 s, claimable once, no secret in it) and asks the steer relay to sendupstream_call {callId}down the device’s control socket (the same socket kill, interrupt and compact use). The device claims the call over its own authenticated API (mcpServers.claimDeviceCall: its own call on that device, else NOT_FOUND), which resolves the member’s credential at claim time; it runs exactly that tool through theemcpclient (http, or the command spawned over stdio), checking the tool’s live annotations against the veto first, and answersupstream_answer {callId, sealed}, sealed to the reply key with eNet’s sealed box (enet-corepush_seal). The relay sees only the id and ciphertext and cannot forge an answer: the reply key never crosses it. - Offline: the relay’s
device_offline=upstreamFailed“runs on , offline”; a read keeps the binding’s default and the host shows that banner (no action) beside the “Connect ” ones; a device that never answers (25 s) = “ : did not answer”. - Grants, audit, cache: exactly as for http upstreams; the answer’s
_metaaddsat.exponential/device= the device’s label.
The eMCP hub (ExponentialOS v5): on a device, one loopback hub combines its servers (ePilot, eDE tools, Spezl apps) behind grants and audit. eApps plug into it as one more ToolProvider, using the same <app>__<tool> names and the same exposes rules, replayed from packages/emcp fixtures in Rust.
Spezl loopback (#YO-32, decided):
- 127.0.0.1 only, with a local token and no OAuth server.
- OpenClaw on the homelab can’t reach it, by design.
- Upstream tokens live in the OS keychain (a
Vaultimplementation in Spezl, not inemcp).
9. Identity and access
Section titled “9. Identity and access”9.1 Roles
Section titled “9.1 Roles”| Who | Can |
|---|---|
| Team owner | Everything. Grants exponential.* actions, sets app_access, publishes, restores, deletes, does on-behalf writes (✅ #VAPP-188) and imports. |
| App editor 🆕 | Edit the package (today any member can, and a non-owner edit clears grants ✅). Proposal: apps.editors (default: creator + owners). Everyone else can only use the app. |
| Member | Use the app within app_access and the kind policy. |
| Guest 🆕 | Through a public link only (§9.4). |
| MCP client | The calling member ∩ grant ∩ policy ∩ tool level (✅ effectiveAccess). |
| Automation | Its sponsor (§7.1). |
9.2 Per-app policy
Section titled “9.2 Per-app policy”app_accessnone/read/write per member (✅).- ✅ (#VAPP-199) It is enforced in tRPC, the shapes (none = the app, its versions and its records never sync, so it is hidden from the Apps list ×4) and the clients’ Data tab.
- ✅ (#VAPP-200) At
readan app’s own writing controls paint disabled ×4: eUI’sreadOnlysurface setting (a node callingrecords.*oractions.*, a writing Form and its fields, a field without an action of its own; abind/setview control stays live; fixtureread-only.json, conformance suiteread-only). The editor preview passes it fromappAccess.mine, or from the Run as member’s level.
9.3 Audit and attribution
Section titled “9.3 Audit and attribution”- ✅
mcp_calls(90 days, args hashed, never stored). - ✅
app_record_events(§3.5, #VAPP-215). - ✅ (#VAPP-229, fixture
via-app.json)via_app_idon comments (#VAPP-186), issues, sessions andapp_records, so every write an app causes reads “via” ×4. Only the server sets it, from the apps broker’s own context: an exposed tool’s run.recordscreate, and the issues and sessions itsrun.actioncreates (later: automations, A1). An app’s own UI writes as the viewer and carries none, because a client cannot claim an app. It shows on the issue detail’s creation row (created the issue · 2h · via Timesheet), as one muted line in the session view’s header, and in the Data tab’s By cell (Ana · via Timesheet). A deleted app shows nothing. ✅ ×4: phones have no By cell (their Data rows carry no byline), so records show it on web and desktop only.
9.4 Public links (🆕 P1, decided: Phase 7)
Section titled “9.4 Public links (🆕 P1, decided: Phase 7)”share: [{name, template, mode: "submit" | "view", kind?, query?, expires?}]:
- submit: a guest fills one
RecordFormof one kind and creates exactly one record.- The guest sees nothing else.
- Rate-limited per IP, with optional email verification.
- The record’s author is “Guest”.
- view: a frozen query rendered read-only. It is a snapshot computed by the server at view time, with no shape and no live sync to the guest.
- URL:
/s/<token>. An owner creates and revokes it, and it appears in Settings › Apps. - Billing: counts as one link, not a seat.
10. Import and export
Section titled “10. Import and export”| Format | Today | Spec |
|---|---|---|
.app.json (package ± records, signed) |
✅ #VAPP-121, #VAPP-161 (4 MB, ≤ 2,000 records) | Records carry author: the export writes it (✅ #VAPP-192, ×4), and the import keeps it when the author is a member of the importing team and the importer an owner; otherwise (a stranger, or a member importing a teammate’s export) the row is the importer’s (✅ #VAPP-188/192). 🆕 An explicit author mapping when importing into another team. |
| CSV / xlsx export | ❌ | Out of scope (decided 2026-11-01). Records leave through .app.json and MCP: an agent reads records_list or an exposed tool and writes whatever file it needs. |
| CSV / xlsx import | ✅ #VAPP-228: the agent recipe (§10.2, CSV + xlsx) and the Data tab’s mapping dialog (§10.3, CSV) | Agent path (✅ §10.2): the agent reads the file in a session and calls records_upsert with author (R2 ✅ #VAPP-188). Dialog (✅ §10.3): paste or choose a CSV → map columns onto the Data tab’s kind (auto-matched by name) → preview the issues → import the ready rows in one transaction (apps.records.batch via: import, ≤ 500 rows), Undo for 10 s. |
| JSON (records only) | 🟡 via records_list |
Covered by .app.json |
Example: importing a timesheet workbook (one sheet per month, two members) into the time-tracker app exercises the agent path end to end: on-behalf writes, then the totals computed by the query instead of by spreadsheet formulas. Reports leave over MCP (an exposed tool such as stunden__month_summary), not as a file export.
10.1 Mapping a spreadsheet onto a kind
Section titled “10.1 Mapping a spreadsheet onto a kind”One set of rules, shared by the agent recipe and the mapping dialog (fixture app-records.json csvImport, replayed ×4):
-
Columns to fields. Compare names with case, spaces and punctuation removed (
Hours worked=hoursworked). A field takes the column with the same name. Failing that, it takes the one column whose name contains the field’s name. A column maps to at most one field. Unmapped columns are skipped. -
Cells to values. A cell is trimmed first. An empty cell = no value: the field is left out, and a required field without a value is an issue.
- text: the trimmed text;
- a string enum: the matching value, ignoring case;
- number and integer:
6.5and6,5both read 6.5 (a lone comma is a decimal comma);- with both
.and,, the LAST one is the decimal separator and the other one groups thousands (1,240.5,1.240,5); - an integer must be whole;
- boolean:
true/yes/y/1/xandfalse/no/n/0, ignoring case; date:YYYY-MM-DD,DD.MM.YYYYorD.M.YYYY. A slash date (3/4/2026) is ambiguous, so it is an issue;time:H:MMorHH:MM[:SS], padded toHH:MM(seconds kept);date-time: ISO 8601 only;- a list: the cell split on
,(or;), every item read by the item type.
A cell that does not read as its field’s type is an issue naming the column and the row (
row 4: hours: "three" is not a number). Then the record goes through the kind’s schema (validateRecord), the same check the server runs. -
Authors. A column mapped to the author (owners only, the on-behalf writes of §3.2) matches a member by email, else by name, ignoring case. Any other value is an issue.
10.2 The agent recipe (✅ X2 step 1)
Section titled “10.2 The agent recipe (✅ X2 step 1)”When a person attaches a CSV, TSV or xlsx to an editor chat (or any session that may write the app), the agent:
-
Reads the file.
exponential_attachments {op: "get", id}, then download itsdownloadUrlinto the working directory and read it with real tooling: for xlsx, a spreadsheet library, one table per sheet. Header rows, merged title rows and total rows are not records. -
Picks or declares the kind. Use an existing kind of the app when its fields fit. Otherwise propose a kind: one field per kept column, typed by the rules above (
dateformat for dates,numberfor amounts and hours, an enum for a column with a handful of repeated values). Then save it withexponential_apps {op: "upsert"}before writing records. -
Dry run. Map every row with §10.1. Do not write anything yet. Show the person the mapping in ONE
exponential_showcard:- the column → field pairs;
- the first rows as a Table;
- the count of rows ready;
- every issue as
row N: field: message;
ask them to confirm or correct. A slash date, an unknown member or an ambiguous column is asked about, never guessed.
-
Writes in batches.
exponential_apps {op: "records_upsert", slug, kind, data}, one call per row. Passauthor(a member’s user id fromexponential_people {op: "list"}) for a row that belongs to someone else (owners only). Send up to 50 calls in parallel per batch, and check each batch’s results before the next. Stop at the first refused batch and report it: a schema refusal names the field, and a plan-limit refusal is the team’s records quota. The writes land asvia: mcpin each record’s history (§3.5), so a person can see and restore them. -
Checks the result.
exponential_apps {op: "records_list", slug, kind, query}with the app’s own query (a sum per month, a count per member) compared with the spreadsheet’s totals. The reply is one line: how many records, and any rows that were skipped.
A NEW app may instead ship its data inside an .app.json file with records (§10 table, ≤ 2,000 records), which the person imports from the Apps list.
10.3 The mapping dialog (✅ X2 step 2)
Section titled “10.3 The mapping dialog (✅ X2 step 2)”The Data tab’s toolbar has an “Import…” button (×4) next to Add record. It opens the existing import dialog pattern: paste or choose a CSV, map, preview, import. xlsx is not parsed on a client: an xlsx goes through the agent recipe.
- Reading the file. CSV per RFC 4180:
- the delimiter is the most frequent of
,,;and tab in the header line, outside quotes; - a BOM is dropped, CRLF and LF both end a row, and quoted fields may hold delimiters,
""and newlines; - blank lines are skipped;
- the first row is the header. An empty header reads
Column N, and a repeated one gets2,3appended; - a row shorter than the header is padded with empty cells. Extra cells beyond the header are an issue.
- the delimiter is the most frequent of
- Mapping. One labelled Select per field of the picked kind (the Data tab’s kind,
dataColumnsorder). Its options are the columns plus “Skip”, and the first pick comes from §10.1. A team owner also gets an Author Select first, defaulting to “Me” (the importer). - Preview. The first rows, each with its issues inline. Below them, a count:
{n} of {total} rows ready. - Import. Writes the ready rows only (rows with issues are skipped). It is ONE
apps.records.batchwithvia: import: one transaction, at mostcsvImport.maxRowsrows, judged against the team’s quota as a whole (over = the plan-limit toast). A success toasts the record count with the existing Undo action for 10 s (§3.5 undo: every created record deleted again).
11. Authoring
Section titled “11. Authoring”- Editor-first (✅ #VAPP-176):
- “New app” opens the editor on an Untitled draft.
- The agent names it.
- Leaving an empty draft deletes it.
- Layout (#VAPP-177–179, in review):
- Docked chat with tabs Preview | Data | Versions | Settings.
- Phones: Chat | Preview | Data plus a
…menu.
- The agent path (✅):
- The chat is the member’s editor session. It reads the catalog through
exponential_apps {op: "catalog"}(prompt budget ≈ 8k tokens, components served one at a time). - It writes with
upsert(one version per write, with a note) and seeds and inspects data withrecords_*. - Any coding agent (Claude Code, OpenClaw) can do the same through MCP.
- The chat is the member’s editor session. It reads the catalog through
- Testing 🆕 T1:
- The package validator ✅ runs on every upsert.
- ✅
testsin the package (T1, #VAPP-196):[{name, records?, now?, as?, expr | query | action, expect}](≤ 50), run against their own sample records in an in-memory store at every install (every upsert; the MCP upsert answerstests: "<n> passed") and in CI; a failing test is an install issue (/tests/<i>, expected vs got), like a type error.src/host/tests.ts/eui_core::host::tests(FFIrun_package_tests_json), fixturefixtures/package-tests.jsonreplayed in TS, Rust, Swift and Kotlin; docs/package-format.md “Tests”. - Preview at phone and desktop widths, with a screen picker.
- ✅ (#VAPP-198) “Run as
” in the preview toolbar (web ≥ md + desktop; owners): me= them, the records their role and access may read, every write refused with a toast. A test’sas+rolerun the same rule in CI.
- Publishing: draft → published (§2.3). Publish runs the migration check (§3.6) and shows the diff of
exposesand automations. Changes to tools need an owner, as grants do today.
12. Security
Section titled “12. Security”12.1 Prompt injection between apps
Section titled “12.1 Prompt injection between apps”The rules decided in #VAPP-175, now ✅:
- an app sees only its own upstreams and records;
- the broker runs exactly one binding per call and never chains across apps;
- results carry
_metaprovenance (at.exponential/app,tool,via,upstream); - descriptions are frozen per version;
- read-only and destructive hints are set, and destructive runs are refused at save and at call.
Added here:
- Data is never instructions.
run.agentand webhooks pass records and payloads as a fenced, typed data block. The agent prompt is the package’s frozen text, never interpolated record text. - Cross-app reads:
records:<kind>?app=<slug>lets an app read another app’s rows today. 🆕 The target app must list the reader inrecords.<kind>.readableBy, and the kind’s policy still applies (decided 2026-11-01). - Rendered text: record strings render as text.
Markdownrenders links through the URL policy ✅, and images go through the media policy (#VAPP-157 ✅).
12.2 Data isolation
Section titled “12.2 Data isolation”- Every query is scoped by
(team_id, app_id), and shapes are member-only ✅. - RLS backstop ✅ (#VAPP-193). The records service runs each operation in a transaction: the checks and every other table first (membership,
app_access, the app row, plan and quota), thenSET LOCAL ROLE eapps,eapps.teamandeapps.apps(the hosting app plus any read targets), andstatement_timeout = 2s. The role hasSELECT, INSERT, UPDATEonapp_recordsand nothing else (deletes are soft). A policy (USING+WITH CHECK) limits it toteam_id = eapps.team AND app_id = ANY(eapps.apps). A wrong query then sees no other team’s or app’s rows, cannot read any other table, cannot write outside its scope, and stops after 2 s. The structured query becomes a parameterised query over a fixed column list, so app text never becomes SQL. The insert trigger that readsappsisSECURITY DEFINERwith a pinnedsearch_path. The main role owns the table and bypasses RLS: the watchdog purge, the quota count, cascades and Electric (replication and snapshots) are unchanged, and the shape proxy stays their guard. The role and policy live in the idempotent custom SQL applied at boot, so self-host gets them too. The connection’s role needsCREATEROLE(or superuser) once to createeapps. - 🆕 The
authorpolicy filters rows in the shape itself. - Spezl keeps records in a per-app eNet records space.
12.3 Secrets
Section titled “12.3 Secrets”- The vault:
VAULT_KEYwith a kid, AES-256-GCMev1, rotation by a daily re-seal ✅. - What is sealed: upstream credentials (one per member and server), better-auth tokens, webhook secrets ✅ (A3,
app_hooks). - No secrets in packages: 🆕 the validator refuses string literals that match key patterns (
sk_live_,ghp_,xoxb-, PEM). - Natives: upstream tokens never reach a client; the server broker uses them, or (M2) the member’s OWN device-routed server receives the member’s own credential at claim time, never stored with the call (Spezl: the device’s keychain).
13. Runtime: where things run
Section titled “13. Runtime: where things run”| Executor | Runs | When | Status |
|---|---|---|---|
| Client host ×4 (web, desktop, iOS, Android) | Bindings, queries, actions, forms | While the app is open | ✅ |
| Server broker | exposes, upstream reads (M1), automations (A1), webhooks (A3), public links (P1) |
Always (the web server process) | ✅ for exposes and upstream reads, 🆕 for the rest |
| Agent | Anything that needs judgement (run.agent) |
On trigger | ✅ A2 |
| Device | stdio/LAN upstreams (M2) | When the device is online | ✅ (#VAPP-239) |
| Spezl (no server) | Everything above, in-app; automations only while the app runs (eagent-hub scheduler) | Local | #YO-32, 🆕 S3 |
Hosted apps (#VAPP-101) stay cancelled. Code that must always run lives in an upstream MCP server the team deploys: a company’s own MCP, a small Bun service, a Home Assistant add-on. The app calls it by name. That keeps Exponential free of a sandbox, of a per-app process model and of billing for compute (decided 2026-11-01: for good).
Self-hosted gets everything: the broker is the same image (ghcr.io/niach/exponential-web).
14. Sync, offline, conflicts
Section titled “14. Sync, offline, conflicts”| Platform | Read | Write | Status |
|---|---|---|---|
| Web | Records on demand per app (subsets) |
Online (tRPC) | ✅ / offline ❌ (banner) |
| Desktop | Full shape | Outbox (O1) | ✅ |
| iOS (GRDB), Android (Room) | Full shape | Outbox (O1) | ✅ |
| Spezl | Local sqlite + eNet records space | Local first | #YO-32 |
| eDE | Through the eapps crate (Exponential or Spezl store) |
Same | 🆕 S4 |
✅ O1, the outbox (#VAPP-235, fixture app-records.json outbox, TS reference @exp/domain-contract/app-records outbox*). On desktop and the natives, every record write an app makes (records.upsert, records.delete, records.batch) is applied locally at once and queued:
- The entry: one per call (a package action’s batch stays one entry: all or nothing), persisted per account in the host’s store (desktop sync sqlite, GRDB, Room), so it survives a restart. The visible rows = the synced rows with every entry laid over them, oldest first.
- Ids: the client generates the uuid of every create (
newId) and of every write (writeId, the idempotency key). The records service answers awriteIdit already logged (app_record_events.write_id, same app and actor) with the first write’s result and writes nothing again ✅. - Order: FIFO, one request at a time, on every write, when the connection comes back and at launch. A transport failure or a server error stops the flush; nothing overtakes it.
- Patches: shallow (field-level). The server merges them last writer wins per field, ordered by server receive time, and logs the R4 history event like any write.
- Delete wins over a concurrent update: a single update or delete answered NOT_FOUND is dropped silently.
- Refusals: a write the server refuses (policy, schema, quota, transition) rolls back locally (its entry leaves) and shows the failed-action toast.
- Acked entries keep overlaying until the shape reflects them (or 30 s).
- Pending: while an app has unsent writes, its header shows the existing
ui-offlineglyph, labelled{n} changes waiting to sync. - What can’t queue:
exponential.*actions and upstream calls. Their buttons are disabled offline, with the reason in the tooltip (🆕).
The web stays online-only. Issue and comment writes have their own NEW-13.
15. Performance limits
Section titled “15. Performance limits”| Limit | Value | Today |
|---|---|---|
| Package | 128 KB, 500 components per template, 16 kinds | ✅ |
| Record | 16 KB | ✅ |
| Records per team | 1,000 / 25,000 / unlimited | ✅ |
| Rows per binding result | 500 (cursor for more) | ✅ (no cursor) |
| Local query | p95 ≤ 16 ms over 25k rows on a mid phone (eui-core), gated in the eUI bench | 🟡 #VAPP-202: measured 2026-11-03 at 1–16 ms p95 on a desktop CPU (eui-core release, examples/records_query_25k.rs), so a phone missed it on grouped/sorted queries. Now each row’s sort keys are read once (numbers kept, texts ranked), field values are borrowed instead of stringified, filter bounds parse once per query, and group keys are computed once per row (eui-core and the TS twin; Swift and Compose call eui-core over the FFI). Results are byte-identical and records-query.json is green ×4. Gated in the eUI bench as records-25k (core + web, the matched count gates; core timings are budgeted at 5 ms p95 = the phone line ÷ 3). The CI bench numbers and a mid-Android run are still owed |
| Server query (web on-demand, broker) | ≤ 200 ms p95, statement_timeout 2 s |
✅ measured: 15–27 ms p95 over 25k rows (records service on Postgres 17); the 2 s timeout is the RLS scope’s |
| Exposed tools per app | 32 | ✅ |
| Upstream read cache | 60 s per (member, tool, args) | 🆕 |
| Automations | ≤ 20 per app; schedules ≥ 15 min apart; ≤ 2,000 runs per team per day | 🆕 |
| Webhooks | 256 KB body, 60 calls per minute per hook | 🆕 |
Import (.app.json) |
4 MB, 2,000 records | ✅ |
16. Spezl and eDE: what they need
Section titled “16. Spezl and eDE: what they need”Spezl (#YO-1, #YO-4 shared format ✅, #YO-32):
- The records query and the persistent
RecordStoretrait inside eUI, not in Exponential’sdomain-contract(S1). Spezl’s eUI 0.2.0 lacksmonth/by/sum/userand only hasMemoryRecordStore. - The
eappscrate (S2, #EXP-1420):AppHost(package validation, bindings, policy), the query evaluator (eui-core), aRecordStoretrait with sqlite and eNet implementations, theexposesexecutor onemcp(records/action/upstream), the migration runner. - A keychain
Vaultand loopback serving (#YO-32). exponential.*actions become anexponentialupstream (ExponentialConnector) when the person connects an Exponential account. Without one, the action is unavailable.- Multi-user app state (#YO-27, parked) maps to eNet spaces later. Without it, Spezl apps are personal: the kind policy collapses to “me”.
eDE (NEW-10):
- Apps run through the same crate in eDE’s shell process. eUI on disk (NEW-107) loads
~/.config/ede/apps/*.app.jsonwith the same validator. - Widget templates (C26):
screens[].widget: {template, size: "s" | "m" | "l"}are painted in the eDE panel and desktop. The same entry gives iOS/Android home-screen widgets later. It depends on the slots model (#VAPP-64). - The eMCP local hub (#EXP-1507) lists the apps’ tools to on-device agents, using the same
exposes.
17. Gap list and roadmap
Section titled “17. Gap list and roadmap”They are ordered by dependency. Every item lands ×4 (web, desktop, iOS, Android) with a fixture change first, per the repo rules. ★ = approved on 30.10 in thread 2db0b7d0. The first milestone is filed and done (✅, issues under #VAPP-175); the rest are proposals, not filed yet.
Phase 1: Records you can trust
- R1 ★ ✅ #VAPP-187 Record ownership:
author_id, the default policyupdate/delete: author, owners override. Server, shape, fixtureapp-records.json. (C1) - R2 ★ ✅ #VAPP-188 On-behalf writes:
authoronrecords_upsert,.app.jsonimport and the Data tab, owners only. Auditcreated_by ≠ author. (C2) - R3 ✅ #VAPP-199 Per-kind policy (
read/create/update/delete), with a shape row filter forread: author.app_accessenforced in tRPC, the shapes, RLS and the Data tab ×4 (an app’s inputs at read: #VAPP-200). (C4) - R4 ✅ #VAPP-215 Record history (
app_record_events), a 10 s undo, the History section ×4. (§3.5) - RLS ✅ #VAPP-193 One records service on the server + the Postgres scope (role
eapps, team + app) under every server read and write of a record. (§3.4, §12.2)
Phase 2: The query (unblocks reports and dashboards)
- S1 ★ ✅ #VAPP-189 Move the records query out of
domain-contractinto eUI host records (TS + eui-core), with a pluggableRecordStore. The ×4 hand derivations become one. (C25) - Q1 ★ ✅ #VAPP-190 The structured query: group-by on any field + time buckets, all aggregates,
before/running sums,in/between/or. Fixturerecords-query.json×4 (TS, Rust, Swift, Kotlin).searchand a cursor stay open. (C5, C6) - Q2 ★ ✅ #VAPP-191 Expressions: mul/div/round/min/max, array aggregates, date maths in the team timezone, the string form. (C7)
- Q3 ✅ #VAPP-214 Computed fields:
records.<kind>.computed, evaluated on read ×4 + the server, queryable, typed at install. (C11) - Q4 ✅ #VAPP-216 The broker’s
recordsrun uses the Q1 evaluator: tools get the same query as the UI. A list run carries any JSON query key (whereas a list,sort,group,aggregate,running,before,sum,mine,user,month,limit) or names a package query (query: "<name>", its${param}s filled from the call’s input); MCPapps records_listtakesquerytoo. The server reads the caller’s rows of the kind (read policy + RLS, an objectwhere= the jsonb containment prefilter, capped at 25,000 = the Team plan quota,truncatedbeyond), then runsrecordsValue; the answer is the binding’s value (rows,count,groups?,totals?…) with the usual_meta.appand anotherfromare refused. Q3’s computed fields reach tools once the evaluator has them. (C15) - E1 ✅ #VAPP-194 Typed effects: pure bindings vs all-or-nothing actions with
require(§4.5). Before A1. - E2 ✅ #VAPP-195 Optional types (
T?) in the checker + a fallback on every binding (§4.2). With or before Q3.
Phase 3: Multi-screen apps (unblocks CRM, hiring, approvals)
- ✅ U1 (#VAPP-218)
screens+navigate+ deep links + phone bar + theformFactorcondition. (C8, C27) - U2
RecordFormandRecordTablemacros: styleguide entry ×5 sites, eUI catalog, prompt budget trimmed to fit. (C9) - R5 Refs +
expand(✅ #VAPP-219). R6 Transitions (✅ #VAPP-222). R7 ✅ #VAPP-221 Files on records. (C3, C10, C20) - V1 ✅ #VAPP-210 / #VAPP-211 Draft vs published (
published_version, Publish, tools and automations on published). (§2.3) - R8 Declarative migrations + the sweep. (§3.6)
Phase 4: Data in, and testing
- X2 Spreadsheet import: the agent recipe first (✅ #VAPP-228, §10.2, in the editor brief and the
exponential-appsskill), the mapping dialog second (✅ #VAPP-228, §10.3, ×4, fixtureapp-records.jsoncsvImport). (C13) - T1 ✅ #VAPP-196 Package
tests(sample records + expected values for expressions, queries and actions), run at install (a failing test is an install issue) and in CI. The “Run as” preview ✅ #VAPP-198. (§11)
Phase 5: Automation and upstreams
- A1 ✅ #VAPP-220 Automations: schedule, record and event triggers, one run, a sponsor, audit, the loop guard and the rate cap;
automations.json(TS + Rust), the server executor on the watchdog tick, Settings ×4. (C18, C23) - A2 ✅ #VAPP-236
run.agent+ thehandoffaction: an automation, an exposed tool or a press hands judgement to a team agent throughsessions.send(the brief = the frozen prompt + a fenced data block + the record ref),via <app>, 10 hand-offs a minute per app, the agent loop guard;exposes.jsonagent+automations.json(TS + Rust),exponential.apps.handoff. (C19) - A3 #VAPP-237 Inbound webhooks with vault-sealed secrets: ✅ the contract (
automations.jsonwebhook: signature, window, replay),POST /api/apps/hooks/<appId>/<name>, the sealed per-hook secret, the nonce cache, the rate cap and audit; ✅ the Settings mint/rotate rows ×4 (#VAPP-238). (C22) - ✅ M1
upstream:read bindings through the broker, with a 60 s cache. (C17, #VAPP-230) - ✅ M2 Device-routed (stdio/LAN) upstreams, routed down the device’s control socket with the answer sealed by eNet’s sealed box. (C17b, #VAPP-239)
- M3 ✅ #VAPP-229
via_app_idon issues, sessions and records ×4. (§9.3)
Phase 6: Everywhere
- ✅ O1 Offline outbox for record writes (desktop, iOS, Android). (C24, #VAPP-235)
- S2 ★
eappscrate + release (eapps-v*). (C25) - S3 Spezl runs eApps: #YO-32 on S2 (keychain vault, loopback,
exponentialupstream). - S4 eDE widgets + apps on the crate. Depends on #VAPP-64 and NEW-107. (C26)
- U3
Calendar/Timeline: styleguide first, then eUI. (C29)
Phase 7: Sharing
- P1 Public links (
submit/view). (C21) - P2 ✅ #VAPP-234 Template gallery: Time tracker, CRM and Standup ship as signed
.app.jsonapp files insamples/apps(the package-format signature, verified server-side against the pinned key infixtures/app-templates.json;bun packages/domain-contract/scripts/sign-app-templates.tsre-signs them withEXP_APP_TEMPLATES_SEED). The import dialog/sheet opens with a “Start from a template” list (glyph, name, one line) and an “Include sample records” switch; a draft’s starters add a “Start from a template” pill opening it. A pick =apps.importTemplate= the import (v1 published, sample records optional), then its editor ×4. Leave and On-call follow. (C28) - P3 A signed catalog shared with Spezl (#YO-18). (C30)
R1 ─ R2 ─ R3 ─ R4S1 ─ Q1 ─ Q2 ─ Q3 ─ Q4 ├─ E2 ─ Q3 └─ E1 ─ A1 · E1 ─ T1 └─ U1 ─ U2 ─ R5/R6/R7 ─ V1 ─ R8R2 ─ X2 · U2 ─ T1V1 ─ A1 ─ A2 ─ A3 · Q4 ─ M1 ─ M2 · M3R3 ─ O1 · S1 ─ S2 ─ S3 / S4 · U3R3 + U2 ─ P1 · V1 ─ P2 ─ P3First milestone: R1, R2, S1, Q1 and Q2 ✅ (2026-11-02, plus the RLS scope #VAPP-193). Records with owners, on-behalf writes and one query evaluator on every platform: enough for real team tools such as the time-tracker example. Open from it: text search and a cursor in the query (the team time zone landed in Milestone G, #VAPP-201).
18. Decisions (Danny, 2026-11-01)
Section titled “18. Decisions (Danny, 2026-11-01)”- No CSV/xlsx export. Records leave through
.app.jsonand MCP (C12 is out of scope; X1 dropped). - Automations: one trigger runs one binding in the server broker; anything with judgement goes to an agent (§7).
- Draft vs published: yes,
published_version+ Publish (§2.3). - Unattended runs act as the sponsor member (§7.1).
- Public links: in scope, Phase 7 (§9.4).
- Cross-app reads stay, with an opt-in
readableByon the target kind (§12.1). - Code lives behind an upstream MCP server; #VAPP-101 stays cancelled for good (§13).
- No app-specific platform features. The platform provides generic date/time maths and aggregates. Rules of one app (an hours target, working days, holidays) are that app’s own records and expressions; there is no team holiday setting.
19. Decisions (Danny, 2026-11-02)
Section titled “19. Decisions (Danny, 2026-11-02)”- No sandbox, no server-side app code. An app stays data; nothing in a package runs on the server. Code still lives behind an upstream MCP server (§18.7).
- Expressions are a CEL subset (§4.2), as TS + Rust twins replaying one fixture, instead of a grammar of our own. Expressions are type-checked against the kind schemas at package validation and run under fixed cost caps. There is no solver or SMT verification: the per-kind policies (R3) stay a fixed enum. If policies ever become free expressions, use Cedar rather than CEL.
- A Postgres RLS backstop scoped to team + app under one records service (§3.4 layer 5, §12.2). Ownership and policy stay in the service, not in RLS. The structured query becomes a parameterised query, so app text never becomes SQL.
- eApps keeps no backwards compatibility (until there are real apps): no compat shims, aliases or old-format fallbacks. A package names
formatVersionand a host reads only its own; records have one spelling (records:<kind>,records.*; Exponential’sexp:records?kind=andharness.records.*are gone); breaking checker changes (E2) refuse old packages on upsert, naming the path, and the editor agent fixes them.
20. General availability (Milestone G, released 2026-11-03)
Section titled “20. General availability (Milestone G, released 2026-11-03)”eApps is production-ready (GA) on web, desktop, iOS and Android. Milestone G closed the five gaps the batch-1 verdict below named. Releases: web 5325cdb (migrations 0238–0240), eUI 0.8.0, desktop/CLI 0.14.112, Android 0.14.100, iOS 0.14.94 (TestFlight). Follow-ups: eUI 0.9.0, desktop/CLI 0.14.113 and web 0.20.29 add Q3 computed fields and Q4 tools on the full query.
What a team can rely on:
- an edit is a draft until Publish, and members, tools and automations run the published version (V1);
- install refuses broken surfaces and values that may be missing (#VAPP-203, #VAPP-204);
- one team time zone (#VAPP-201);
- read-only access is enforced and shown (#VAPP-199, #VAPP-200);
- the local query is optimised and gated in the eUI bench (#VAPP-202; the CI and mid-phone numbers are still owed);
- built-in skills teach agents the format (#VAPP-213).
Rich apps continue in §17 order: R4 history, then U1 screens and the rest.
Dogfood G (2026-11-03, staging, the “eApps Dogfood” team: an owner and a member). The time tracker sample (samples/apps/time-tracker.app.json 1.1.0) now uses every Milestone G feature: computed minutes + week and a weeks query grouped on them (a “This month by week” table), and two exposes tools (week_summary runs the weeks query, log_hours writes). Its package tests pass at install (17). Verified over MCP and on web:
- V1: an upsert lands as a draft (
publishedstays). The member’s editor shows the published version, and/api/mcp/appslists and runs the published package’s tools (none before the first publish). Publish moves them, andpublish {version: 2}rolls back: the tool’s title and the query’s sort follow at once. - Q3 + Q4:
time-tracker__week_summaryreturnsgroupskeyed by the computedweek, with the computedminutessummed, per member (read policyauthor).log_hourswrites as the caller. - Read-only: with app access
read, the member’s forms paint disabled (the Log hours dialog’s Save too). The write tool disappears from the list, andrecords_upsert,records_delete, tRPCrecords.upsertandrecords.batchall answer “You have read-only access to this app”. Reads still work. - Install checks: 18 broken variants of the time tracker (unknown component, missing prop, dangling child, computed shadowing/chaining/typos/types, typos in a row template, unknown queries in bindings and tests, destructive exposes) are refused at the right path. Two gaps were fixed: a query on a field its kind lacks (or a sum of a text) and an exposes run naming no query (#VAPP-223, PR #1793).
- Team zone: with the team on
Pacific/Kiritimati,today()read the team’s day, but the records month view’stodayand its default month read UTC’s on every host and in the broker (the Log hours dialog opened on yesterday). Fixed: the records context takes the host’stoday(#VAPP-224, PR #1794;records-query.json, ×4 + the server).
20.1 Readiness (batch 1, 2026-11-03)
Section titled “20.1 Readiness (batch 1, 2026-11-03)”Done in batch 1 (approved by Danny 2026-11-03): the CEL differential fuzz in CI (#1719), R3 per-kind policies + app_access everywhere (#VAPP-199), “Run as” (#VAPP-198), authors in .app.json exports (#VAPP-192), the eApps real-DB suites run against Postgres 17 (all green), the §15 limits measured, and three real apps dogfooded on staging (time tracker, CRM, standup; samples/apps/, #VAPP-209). The dogfood found and fixed a desktop crash on every package action and a web/desktop disagreement on missing fields.
Verdict: ready for real team apps on web and desktop, for teams that build with the editor and accept that an edit is live at once. Records, policies, queries, actions and tests are trustworthy and enforced by the server. It is not yet general-availability. Before calling it that:
- ✅ V1 draft vs published (§2.3, #VAPP-210/#VAPP-211): an edit is a draft until Publish; members and tools run the published version.
- ✅ #VAPP-203: install reduces every template as the renderer will (unknown components, missing/mistyped props, child ids naming nothing), issues at
/templates/<id>/components/<i>…(TS + Rust twins,host-router.jsonacme.badcomponents). It found two real bugs: the built-in Devices template bound non-bindableIconDisc/LiveDotprops (now bindable) and a desktop test package’schevron: "always". - ✅ #VAPP-204: the checker no longer trusts values that may be missing. A records value’s
sum/month/groups/totals/beforefollow the binding’s query (left out =T?), every list index isT?and reads null past the end at run time, a map’s field isdyn?, a row template’s relative paths are typed by the list’s records rows (a typo likehourzfails install at the var’s path),has(x)hintsx != null, and a press’sactions.<name>args and abind’s literal source are checked at install (TS + Rust twins;expressions.json,actions.json). The three sample apps still install unchanged. - ✅ #VAPP-201: the team time zone (
teams.time_zone, Settings › General ×4; hosts read dates in it, else the device’s zone;team-zone.jsonreplays the tz databases ×4). - ✅ #VAPP-200: an app’s own writing controls paint disabled at access
read(eUIreadOnly×7 renderers + the hosts ×4).
Later, not blocking: U1 screens (✅ #VAPP-218: screens + app.navigate, the preview’s sidebar / phone bar ×4, deep links /t/<team>/apps/<app>/<screen> and the natives’ app links, @media (form-factor: …)), O1 offline (✅ #VAPP-235: the natives queue record writes), #VAPP-202 (the local query over 25k rows on phones: optimised and in the eUI bench as records-25k; CI and mid-Android numbers owed), #VAPP-205/206/207/208 (authoring gaps and drift found while dogfooding), and the iOS/Android “Run as” (phones have no preview toolbar by design).