esoul-sdk 0.4.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/README.md +146 -24
  2. package/dist/audience.d.ts +103 -0
  3. package/dist/audience.js +142 -0
  4. package/dist/bindings.d.ts +164 -0
  5. package/dist/bindings.js +163 -0
  6. package/dist/db/client-core.d.ts +169 -0
  7. package/dist/db/client-core.js +316 -0
  8. package/dist/db/compile-rules.d.ts +229 -0
  9. package/dist/db/compile-rules.js +426 -0
  10. package/dist/db/memory-client.d.ts +136 -0
  11. package/dist/db/memory-client.js +332 -0
  12. package/dist/db/schema-gen.d.ts +109 -0
  13. package/dist/db/schema-gen.js +363 -0
  14. package/dist/helpers.d.ts +52 -0
  15. package/dist/helpers.js +106 -10
  16. package/dist/index.d.ts +5 -0
  17. package/dist/index.js +5 -0
  18. package/dist/manifest.d.ts +466 -13
  19. package/dist/manifest.js +218 -5
  20. package/dist/roles.d.ts +43 -0
  21. package/dist/roles.js +56 -0
  22. package/dist/server.d.ts +182 -0
  23. package/dist/server.js +80 -0
  24. package/dist/testing/db.d.ts +71 -0
  25. package/dist/testing/db.js +103 -0
  26. package/dist/testing/index.d.ts +14 -0
  27. package/dist/testing/index.js +9 -0
  28. package/dist/testing/ops.d.ts +84 -0
  29. package/dist/testing/ops.js +76 -0
  30. package/dist/types.d.ts +22 -1
  31. package/docs/04-tools.md +5 -2
  32. package/docs/06-server.md +78 -0
  33. package/docs/07-background-tasks.md +23 -0
  34. package/docs/10-testing.md +18 -0
  35. package/docs/12-rules.md +3 -2
  36. package/docs/13-people-and-access.md +152 -0
  37. package/docs/14-database.md +221 -0
  38. package/docs/15-realtime.md +88 -0
  39. package/docs/16-bindings.md +79 -0
  40. package/llms-full.txt +829 -28
  41. package/llms.txt +4 -0
  42. package/package.json +7 -3
  43. package/schemas/plugin.schema.json +351 -9
package/llms-full.txt CHANGED
@@ -4,65 +4,187 @@ Generated from README.md and docs/. Read it whole. Every rule carries the failur
4
4
 
5
5
  # esoul-sdk
6
6
 
7
- Build **ExternalSoul apps**: native apps for an event-sourced workspace where people and AI
8
- agents share one canvas. An app you write with this SDK is indistinguishable from the platform's
9
- own once it ships — the same events, the same timeline, the same durability, the same tools that
10
- chat, voice, agents and MCP call.
7
+ Build a **full product** on ExternalSoul — not a widget. An app you write with this SDK gets its
8
+ own database tables, its own server, its own background jobs, its own realtime, its own words for
9
+ the people who use it, and the same agent tools that chat, voice and MCP already call. Once it
10
+ ships it is indistinguishable from the platform's own apps.
11
11
 
12
12
  ```bash
13
13
  npm install --save-dev esoul-sdk
14
14
  ```
15
15
 
16
16
  Inside ExternalSoul the package name resolves to the platform's real implementations. Outside it
17
- (your editor, your tests) the package gives you the types, the manifest validator, and the test
18
- helpers; the server functions throw "host only" if called, because they run in the platform.
17
+ (your editor, your tests) it gives you the types, the manifest validator, the rule compiler and
18
+ the test helpers; the server functions throw "host only" if called, because they run in the
19
+ platform.
20
+
21
+ ---
22
+
23
+ ## What you can build with it
24
+
25
+ Take a help desk. Strangers may read the published articles. Someone signed in may raise a ticket
26
+ and see their own. The people on duty see every ticket. The owner decides who is on duty. A
27
+ confirmation must go out exactly once, even if the mail service is down for a minute. That is
28
+ every hard thing at once, and it is the shape most real products have.
29
+
30
+ An app like that, written with this SDK, contains **no access checks at all**. They are
31
+ declarations, and the platform enforces them at the seam.
32
+
33
+ | You want | You declare | You never write |
34
+ |---|---|---|
35
+ | Your own tables | `db` in the manifest | migrations, a Prisma schema, an ORM |
36
+ | Per-person data | `owner: "creator"` + `rules` | a `WHERE userId = …` anywhere |
37
+ | A public page | `"access": "public"` on an op | an auth check in the handler |
38
+ | A sign-in wall | `"requires": "account"` | the difference between "sign in" and "never" |
39
+ | Words for your people | `roles` | a permissions table |
40
+ | One person's live updates | `"audience": "viewer"` on a topic | a filter on arrival |
41
+ | Durable follow-up work | a `task` | a queue, retries, idempotency plumbing |
42
+ | Another app's help | `uses` + a contract | an integration |
43
+
44
+ ---
45
+
46
+ ## The seven capabilities
47
+
48
+ **1. Viewer — every seam knows who is calling.** Ops, routes, tasks, tools and the UI all receive
49
+ a `viewer`: the owner, a member, a signed-in visitor, an anonymous one, an agent acting for
50
+ someone, or your app's own server code. The platform resolves it; an app cannot supply, widen or
51
+ forge one. `useViewer()` in the UI, `ctx.viewer` on the server. When you need the person's name
52
+ or email — to address them, or to fill a form in — `viewerProfile(ctx.viewer)` reads the esoul
53
+ account of the CALLER and nobody else, so people use your app with the account they already have.
54
+
55
+ **2. Roles — your app's own vocabulary.** Declare `roles: { vocabulary: ["requester", "agent",
56
+ "supervisor"] }` and a mapping from the platform's kinds. The workspace owner can then assign a role
57
+ per app, per person, so an organisation sees the pages their job needs. A role is a WORD, never a
58
+ permission: it decides what your rules mean, not what the platform allows.
59
+
60
+ **3. Access levels — nothing opens by default.** Every op, route and task is `write` unless you
61
+ say otherwise. `read` admits a read-only collaborator; `public` admits someone on a share link
62
+ who has no workspace access at all; `public` + `requires: "account"` answers `login-required`
63
+ instead of `forbidden`, which is the difference between showing a sign-in wall and showing an
64
+ error. Every public door is listed on the install card before anyone installs your app.
65
+
66
+ **4. The database — tables you declare, scoped by the platform.** A `db` block becomes real
67
+ tables, generated typings, and rules compiled once and enforced everywhere. `pluginDb(ctx)` hands
68
+ you a typed client whose every query is already scoped to this instance and filtered for this
69
+ caller: one person's `findMany` returns their own rows, someone on duty gets the whole queue, and
70
+ neither had to ask.
71
+ Fields can be `sealed` (encrypted at rest, readable only by their owner). Migrations are
72
+ additive-only and applied on install; a change that would drop or retype a column is refused with
73
+ the column named.
74
+
75
+ **5. Realtime with an audience.** A topic declares who hears it: everyone on the app, only the
76
+ person it concerns, or only a role. The platform mints a token per channel, so one person is never
77
+ handed another's messages — not filtered out on arrival, never issued. Aiming a message is a
78
+ separate permission from hearing one.
79
+
80
+ **6. Background tasks.** Durable work on the platform's own scheduler: retried, replay-safe,
81
+ steps on the timeline. This is where the slow and failure-prone things go — sending the
82
+ confirmation, calling somebody else's API — so the request that changed the record stays fast and
83
+ honest.
84
+
85
+ **7. Bindings.** Declare a SLOT and a CONTRACT (`uses: { rooms: { contract: "rooms/v1" } }`) and
86
+ the owner picks which app fills it. The platform checks at bind time that the provider really has
87
+ every tool, event and table the contract names. Your app reaches it through `ctx.apps.rooms` —
88
+ the contract's tools only, with the caller's identity travelling along.
89
+
90
+ ---
19
91
 
20
92
  ## Where to start
21
93
 
22
94
  - **Build it in the Forge, not on your laptop.** Open a Forge board in your workspace and ask it
23
- to open a workbench for your app. You get a cloud machine with the platform on it, a live
24
- preview in your frame, your app's tools callable before it is installed, checks, and a submit
25
- button. Read [docs/01-getting-started.md](docs/01-getting-started.md).
95
+ to open a workbench for your app: a cloud machine with the platform on it, a live preview in
96
+ your frame, your app's tools callable before it is installed, and **VIEW AS** — the switcher
97
+ that shows you your app as each kind of person who will use it, including the stranger. Read
98
+ [docs/01-getting-started.md](docs/01-getting-started.md).
26
99
  - **The contract, one page per part:**
27
100
  1. [Getting started](docs/01-getting-started.md) — the loop, the package layout
28
101
  2. [The manifest](docs/02-manifest.md) — `plugin.json`, every field
29
102
  3. [Events and state](docs/03-events-and-state.md) — the heart: dataCreator, processor, replay
30
103
  4. [Tools](docs/04-tools.md) — what agents call, on every surface
31
104
  5. [The UI](docs/05-ui.md) — React, hooks, theme, responsive rules
32
- 6. [The server half](docs/06-server.md) — ops, webhooks, reading state, calling other apps
105
+ 6. [The server half](docs/06-server.md) — ops, routes, webhooks, calling other apps
33
106
  7. [Background tasks](docs/07-background-tasks.md) — durable work, polling, the replay model
34
107
  8. [Connections and OAuth](docs/08-connections.md) — tokens the platform holds for you
35
108
  9. [Files](docs/09-files.md) — workspace files, Drive, your own provider
36
- 10. [Testing](docs/10-testing.md) — the fold contract as tests
109
+ 10. [Testing](docs/10-testing.md) — the fold contract, and your rules, as tests
37
110
  11. [Shipping](docs/11-shipping.md) — submit, review, release, install; the import wall
38
111
  12. [Rules and failures](docs/12-rules.md) — every rule with the failure that earned it
112
+ 13. [People and access](docs/13-people-and-access.md) — viewer, roles, levels, the sign-in wall
113
+ 14. [Your own tables](docs/14-database.md) — `db`, rules, scopes, sealed fields, migrations
114
+ 15. [Realtime](docs/15-realtime.md) — topics, audiences, who may address whom
115
+ 16. [Bindings](docs/16-bindings.md) — slots, contracts, reaching another app
39
116
  - **For a coding model:** `llms.txt` (short) and `llms-full.txt` (the whole contract in one file).
40
117
 
41
118
  ## The one rule that explains the others
42
119
 
43
- **Events are the truth.** Your app's state is the fold of its events, re-run on every replay,
44
- scrub and sync. So a reducer must be pure and idempotent, ids and timestamps are minted in the
45
- `dataCreator` (never in a reducer), whole-replace events carry a collapse key, and the agent-facing
46
- state description never claims something it could not read. Everything in the docs follows from
47
- that.
120
+ **Events are the truth.** Your app's fold is its events re-run on every replay, scrub and sync. So
121
+ a reducer is pure and idempotent, ids and timestamps are minted in the `dataCreator` (never in a
122
+ reducer), whole-replace events carry a collapse key, and a state description never claims
123
+ something it could not read.
124
+
125
+ The second rule, for everything that is not in the fold: **the platform decides who sees what.**
126
+ Your rules are declarations the platform enforces at the seam. An app that checks access in its
127
+ own handler has two answers to one question, and one of them will be wrong.
48
128
 
49
129
  ## What is in the package
50
130
 
51
131
  | Entry | What it gives you |
52
132
  |---|---|
53
- | `esoul-sdk` | `ApplicationSchema`, `EventDefinition`, `EventTypes`, `ApplicationIdentifier`, `incompleteStateNotice`, `deterministicReducerId`, `stableStringify`, `timingSafeEqual`, `nanoid`, `callPluginOp`, the manifest schema |
54
- | `esoul-sdk/react` | `usePluginEventDispatch`, `useAppCanEdit`, `usePluginCurrentChatId`, `useWorkspaceTools`, `usePluginRealtime`, file hooks |
55
- | `esoul-sdk/server` | `PluginServerModule`, `readAppState`, `callWorkspaceTool`, `emitPluginAppEvent`, `getPluginConnectionCredentials`, file provider types |
56
- | `esoul-sdk/testing` | a mock OAuth server for connection tests |
133
+ | `esoul-sdk` | `ApplicationSchema`, `EventDefinition`, `EventTypes`, `ApplicationIdentifier`, `definePluginChannel`, `defineBindingEvent`, `checkBinding`, `subscriptionsFor`, `resolveAppRole`, `incompleteStateNotice`, `deterministicReducerId`, `timingSafeEqual`, `nanoid`, `callPluginOp`, `kickPluginTask`, `pluginRouteUrl`, the manifest schema |
134
+ | `esoul-sdk/react` | `useViewer`, `useSignInWall`, `useAppCanEdit`, `usePluginEventDispatch`, `usePluginRealtime`, `useWorkspaceTools`, file hooks |
135
+ | `esoul-sdk/server` | `pluginDb`, `viewerProfile`, `PluginServerModule` (ops, routes, webhooks), `sseStream`, `readAppState`, `callWorkspaceTool`, `emitPluginAppEvent`, `getPluginConnectionCredentials`, file provider types |
136
+ | `esoul-sdk/testing` | `memoryDb`, `fakeViewer`, `runOp`, `fakeApps`, `capture`, `startMockOAuth` — your rules run against the client the platform compiles from your own manifest |
57
137
  | `esoul-app validate <dir>` | validates a package folder against the manifest schema |
58
138
 
139
+ ## Testing your app
140
+
141
+ The helpers run your REAL server code against an in-memory database built from your own
142
+ `plugin.json`, with the same compiled rules the production client uses. What passes here is what
143
+ the real database will do.
144
+
145
+ ```ts
146
+ import { fakeViewer, memoryDb, runOp } from "esoul-sdk/testing";
147
+ import manifest from "./plugin.json";
148
+ import { pluginServer } from "./server";
149
+
150
+ const ada = fakeViewer("visitor", { userId: "u_ada", role: "requester" });
151
+ const lin = fakeViewer("visitor", { userId: "u_lin", role: "requester" });
152
+
153
+ it("does not let the OTHER one see it", async () => {
154
+ const db = memoryDb(manifest);
155
+ await runOp(pluginServer, "raise-ticket", { viewer: ada, args: TICKET, db: db.as(ada) });
156
+ const { result } = await runOp(pluginServer, "my-tickets", { viewer: lin, args: {}, db: db.as(lin) });
157
+ expect(result).toEqual([]);
158
+ });
159
+ ```
160
+
161
+ `runOp` also records what your op NOTIFIED and who it addressed, and `notifyFails` makes
162
+ notifying throw — the question worth asking of any op that tells somebody after it has written
163
+ something: does the write survive?
164
+
59
165
  ## Versions
60
166
 
61
- - **0.3.0** — renamed from `@externalsoul/plugin-sdk` (still resolved as an alias inside the
62
- platform). `readAppState`, `callWorkspaceTool`, `nanoid` on the index. The import wall: an app
63
- reaches the platform only through this package. Docs rewritten for the Forge workbench loop.
167
+ - **0.7.0** — what a catalogue-sized app needs. **Index kinds**: a plain group is a btree,
168
+ `{ fields: ["tags"], kind: "contains" }` answers `{ tags: { has: … } }` on a list, and
169
+ `{ fields: ["title"], kind: "text" }` answers `{ title: { contains: … } }` — so a department
170
+ and a search term are questions for the database, with `cursor` paging. `contains` ignores
171
+ case in both clients (a search box that misses "earl grey" is broken quietly). Asking a list a
172
+ scalar's question (or the reverse) is refused with the right one named. **`ctx.emit`** — an op
173
+ records on its OWN timeline, so a fact reaches the fold whether a person or an agent caused it.
174
+ A list field defaults to `[]` (it used to refuse every create), a rule-less model defaults to
175
+ the role your manifest calls the owner, and `viewerProfile` is a real export rather than a
176
+ declaration. Documented the client surface that was already there: `aggregate`, `groupBy`,
177
+ `$transaction`, the `*Many` writes.
178
+ - **0.6.0** — the full-stack release. Your own tables (`db`, `pluginDb`, rules, scopes, sealed
179
+ fields, additive migrations applied on install). `viewer` on every seam, app `roles`, per-surface
180
+ access levels with the sign-in wall. Realtime audiences: a topic says who hears it and the mint
181
+ issues a token per channel. Bindings: `uses`/`provides`, contracts, `ctx.apps.<slot>`. A tool now
182
+ acts for the person who invoked it rather than for the platform. `viewerProfile` — the caller's
183
+ own esoul account, server-side only. Testing: `memoryDb`, `fakeViewer`, `runOp`.
184
+ - 0.5.0 — server routes (`pluginServer.routes`), `sseStream`, plugin realtime.
185
+ - 0.3.0 — renamed from `@externalsoul/plugin-sdk`. `readAppState`, `callWorkspaceTool`, `nanoid`
186
+ on the index. The import wall: an app reaches the platform only through this package.
64
187
  - 0.2.0 — file sources and providers.
65
- - 0.1.0 — the contract: manifest, schema, events, tools, tasks, webhooks, ops, connections.
66
188
 
67
189
 
68
190
 
@@ -401,8 +523,11 @@ could not do: an update to an id that is not on the wall says "No note X — not
401
523
 
402
524
  A tool that must read the real state (not the caller's cache) calls a **plugin op** through
403
525
  `callPluginOp(pluginId, op, nodeId, args)` — never `fetch("/api/v1/…")` itself (token-gated, it
404
- refuses the tool). Ops are yours to write (docs/06). In the workbench there is no database behind
405
- the preview, so such a tool is refused with a message naming `read_app_state`; that is expected.
526
+ refuses the tool). Ops are yours to write (docs/06). In the workbench an op runs over the
527
+ preview's own in-memory tables, so a tool that calls one works there too. A failed call throws a
528
+ `PluginCallError` carrying the platform's `code` (`forbidden`, `login-required`, …): pass it to
529
+ `useSignInWall().raise(err)` in the UI and a `login-required` becomes the sign-in wall instead of
530
+ an error. Relay any other error's message; never swallow it.
406
531
 
407
532
  ## Calling other apps
408
533
 
@@ -608,6 +733,35 @@ Gated exactly like the UI: the manifest must declare `"my_computer:claude_task"`
608
733
  instance with `targetNodeId` instead of `appType`. This is how an app runs a `my_computer`
609
734
  (commands, Claude Code sessions, results), fills a spreadsheet from a job, or books a calendar.
610
735
 
736
+ ## `ctx.emit` — record on your OWN timeline
737
+
738
+ Anything a person can cause through your UI, an agent can cause through a tool,
739
+ and the server is the only place that sees both. So when a change belongs on the
740
+ fold, the OP records it, not the screen:
741
+
742
+ ```ts
743
+ async function addProduct(ctx: PluginOpContext) {
744
+ const p = await (await pluginDb<ShopDb>(ctx)).product.create({ data: … });
745
+ // Derive the change id. A retry — and the UI's own optimistic dispatch of the
746
+ // same fact — is then a no-op rather than a second bump.
747
+ await ctx.emit("plugin_shop_catalogue_changed", { changeId: `product:${p.id}`, tags: p.tags });
748
+ return { id: p.id };
749
+ }
750
+ ```
751
+
752
+ It is your own event (`eventName` from your schema), it goes through the
753
+ platform's spine — your `dataCreator` mints, your reducer folds, triggers fire —
754
+ and it lands on THIS instance.
755
+
756
+ **The failure this exists to prevent.** An app offered its departments from the
757
+ fold and recorded them only in the owner's add-product FORM. Stocked by its
758
+ agent instead, it therefore had a full catalogue and no departments at all,
759
+ live on a customer's site. If a fact belongs on the timeline, the op is where
760
+ it is written — a screen is one of its callers, never the only one.
761
+
762
+ Treat it as a courtesy around a completed write, like `notify`: catch a failure
763
+ and report it, rather than letting it undo a product that exists.
764
+
611
765
  ## `emitPluginAppEvent`
612
766
 
613
767
  Emit ANOTHER app's own events (its `dataCreator` shape) into the same workspace, actor-stamped as
@@ -625,6 +779,55 @@ Only `esoul-sdk` / `esoul-sdk/server`, relative files, `server-only`, and npm pa
625
779
  platform already depends on. No `prisma`, no `node:*`, no `next`, no internals — the import wall
626
780
  (docs/11) refuses them, and a reviewer relies on that.
627
781
 
782
+ ## Routes — the backend your app brings with it
783
+
784
+ An op answers one JSON question. A **route** owns the whole Response: it can stream, return
785
+ bytes, set headers, and run for as long as one request lasts (minutes on Fluid compute). It is
786
+ how a user app gets what a native app gets from its API routes — mounted on install at
787
+ `GET|POST /api/plugins/<id>/route/<name>?nodeId=<instance>`.
788
+
789
+ ```json
790
+ // plugin.json
791
+ "routes": ["ticks"]
792
+ ```
793
+
794
+ ```ts
795
+ // server.ts
796
+ import { readAppState, sseStream, type PluginServerModule } from "esoul-sdk/server";
797
+
798
+ export const pluginServer: PluginServerModule = {
799
+ routes: {
800
+ // A clock streamed straight from the server: one Node process, no scheduler hops.
801
+ ticks: async (ctx) =>
802
+ sseStream(async (send, signal) => {
803
+ while (!signal.aborted) {
804
+ const app = await readAppState(ctx.nodeId); // the fold, ~0.3 s here
805
+ const cur = (app?.state as { current?: { runId: string; kickedAt: number } }).current;
806
+ if (cur) send("tick", { runId: cur.runId, elapsedMs: Date.now() - cur.kickedAt });
807
+ await new Promise((r) => setTimeout(r, 1000));
808
+ }
809
+ }, { signal: ctx.request.signal }),
810
+ },
811
+ };
812
+ ```
813
+
814
+ ```tsx
815
+ // ui — the session rides along; a public-share viewer can watch, not write.
816
+ import { pluginRouteUrl } from "esoul-sdk";
817
+ const es = new EventSource(pluginRouteUrl("stopwatch", "ticks", state.nodeId));
818
+ es.addEventListener("tick", (e) => setTick(JSON.parse((e as MessageEvent).data)));
819
+ ```
820
+
821
+ The platform resolves the instance, the enabled flag and the caller's access before your
822
+ handler runs — READ to reach the route at all, and `ctx.canWrite` says whether the caller may
823
+ mutate (check that one boolean before you `emitPluginAppEvent`). An internal caller
824
+ (`INTERNAL_TOOL_SECRET`) is a writer.
825
+
826
+ **Route or task?** A route lives exactly as long as a request: close the tab and the clock in
827
+ the example stops streaming — nothing durable happened unless the handler wrote events. A
828
+ task (docs/07) survives everything and costs seconds per hop. The stopwatch ships both and
829
+ records which one drove each run; measure before you choose.
830
+
628
831
 
629
832
 
630
833
  ==============================================================================
@@ -677,12 +880,35 @@ replay; mint them inside. Step names are unique per logical operation; loops inc
677
880
  `kickPluginTask({ applicationType, taskName, identifier, data })` (from `esoul-sdk`; works in
678
881
  a component and in a tool's `execute`). The platform's send-event route allows only listed
679
882
  tasks and stamps nothing — `data` is exactly what `ctx.eventData` sees.
883
+ - **In a Forge preview** the same call runs the task IN the preview's dev server, behind the
884
+ same `ctx` (steps, `getState`, `dispatchEvent`, `notify`), against an in-process store the
885
+ page mirrors — so you can press Start in the frame before the app is installed. What the
886
+ preview does not give you: durability across a process death, retries, a scheduler, and the
887
+ platform's `if` on `waitForEvent` (the preview matches the event name and your node).
680
888
  - **On a cadence**: `pollTasks: [{ task, everyMinutes }]` — one shared platform sweep kicks each
681
889
  live instance at 5-minute granularity (minimum 5). This is your cron. There are deliberately
682
890
  no per-app Inngest functions: function ids are fixed at module load, and the plan caps
683
891
  concurrency; a shared sweep costs nothing per app. Handlers must be idempotent anyway, because
684
892
  sweeps and pushes overlap by design.
685
893
 
894
+
895
+ ## Task or route? Measured (2026-09-10, the stopwatch)
896
+
897
+ | | a server route (docs/06) | a durable task |
898
+ |---|---|---|
899
+ | kick → first tick | 0.2 s | 2–15 s |
900
+ | stop landed → run finished | 0.8 s | 0.7–12 s |
901
+ | tick cadence | 1 s | ~3 s |
902
+ | a fold read | 60 ms | ~2.8 s |
903
+ | survives the tab closing | no — nothing outlives the request | yes — everything |
904
+ | survives a deploy / crash | no | yes |
905
+
906
+ Every step of a task is a round trip through the scheduler into the platform's function.
907
+ So: **the user's time is measured on the user's clock** (the two presses), the task is the
908
+ durable witness, and the route is the live view. Ship both when the difference matters, and
909
+ record which one drove each run — the timeline is the measurement. Never make a task keep
910
+ sub-second time; never make a route keep a promise past its request.
911
+
686
912
  ## Concurrency
687
913
 
688
914
  `concurrency: { limit, scope: "per-app" | "global" }`. Keep the limit small; a parked wait holds
@@ -851,6 +1077,24 @@ platform) and commit `fold-corpus.json` + hashes. From then on the checks refuse
851
1077
  alters what history MEANS — the strongest protection an app with data can have. Re-recording is
852
1078
  declaring a migration; say so in the changelog.
853
1079
 
1080
+ ## What runs in a Forge preview (parity)
1081
+
1082
+ Since 2026-09-10 a workbench keeps an in-process stand-in for the database, Inngest and the
1083
+ broker, so before the app is installed:
1084
+
1085
+ | | in the preview | not in the preview |
1086
+ |---|---|---|
1087
+ | events, tools (`call_app_tool`), `read_app_state` | yes | |
1088
+ | tasks (`kickPluginTask`, from the UI or a tool) | yes — same `ctx`, in-process | durability, retries, a scheduler; `waitForEvent`'s `if` (name + node only) |
1089
+ | ops (`callPluginOp`, `readAppState`) | yes — on a preview-only route, over the preview's fold | a real database |
1090
+ | routes (`pluginRouteUrl` → a preview-only route) | yes — the caller is a writer | auth (the sandbox is single-tenant) |
1091
+ | realtime (`ctx.notify` → `usePluginRealtime`) | yes — polled from the store | the broker |
1092
+ | `emitPluginAppEvent` from a route or op | yes | cross-app targets outside the preview |
1093
+
1094
+ The board is told which of these a box has: the preview announces `tasks`, `ops`, `routes`,
1095
+ `realtime` in its greeting once the store answers, and the frame reloads itself when the
1096
+ server side is a newer build than the bundle it runs.
1097
+
854
1098
 
855
1099
 
856
1100
  ==============================================================================
@@ -963,5 +1207,562 @@ platform it runs on.
963
1207
  - "State reverts after reload" → `reconstructStateFromEventLog` unset, or a processor minted ids.
964
1208
  - "check_app is red on registry" → the manifest failed validation or the import wall refused a
965
1209
  file; the detail names it.
966
- - "My tool needs the database" → write an op; call it with `callPluginOp`; expect a refusal in
967
- the workbench (no database behind a preview) and use `read_app_state` there.
1210
+ - "My tool needs the database" → declare the tables in the manifest's `db` and write an op over
1211
+ `pluginDb(ctx)`; call it with `callPluginOp`. The workbench runs it over in-memory tables with
1212
+ the same rules, so VIEW AS shows what each person may read.
1213
+
1214
+
1215
+
1216
+ ==============================================================================
1217
+ # 13 · People and access
1218
+
1219
+ Your app will be used by people with different relationships to it: the person who installed it,
1220
+ the people they invited, and everyone else. This page is how the platform tells them apart, and
1221
+ how you say what each may do — without writing a single access check.
1222
+
1223
+ ## The viewer
1224
+
1225
+ Every seam of your app receives a `viewer`. The UI reads it with `useViewer()`; the server gets
1226
+ `ctx.viewer` on an op, a route and a tool; a task carries `ctx.kickedBy` (who asked) and runs as
1227
+ your app's own code.
1228
+
1229
+ ```ts
1230
+ viewer.kind // "owner" | "member" | "visitor" | "anonymous" | "agent" | "internal"
1231
+ viewer.userId // the account, or null
1232
+ viewer.role // YOUR app's word for them (see below)
1233
+ viewer.canWrite // may they change the workspace at all
1234
+ ```
1235
+
1236
+ - **owner** — whose workspace this is.
1237
+ - **member** — someone the owner invited, with edit or read-only access.
1238
+ - **visitor** — signed in, but not in the workspace: someone on a share link.
1239
+ - **anonymous** — not signed in. Still has an identity (a cookie) so rows they create can be
1240
+ theirs, and stay theirs after they sign in.
1241
+ - **agent** — a run acting for one of the above. It gains nothing: everything about what it may
1242
+ see comes from the person it acts for.
1243
+ - **internal** — your own app's server code (a task, the install job). Rules do not apply to it;
1244
+ scope still does.
1245
+
1246
+ The platform resolves this. An app cannot supply, widen or forge one — the import wall keeps you
1247
+ from reading a cookie or a header to invent an identity.
1248
+
1249
+ ## Who they actually are
1250
+
1251
+ A viewer is an identity, not a person's details. When your app needs the details — a name to put
1252
+ on a confirmation, an address to send one to — ask for the CALLER's own account:
1253
+
1254
+ ```ts
1255
+ import { viewerProfile } from "esoul-sdk/server";
1256
+
1257
+ const me = await viewerProfile(ctx.viewer); // { userId, email, name, picture } | null
1258
+ ```
1259
+
1260
+ Server only, one database read, and only ever about the caller: there is no argument for whose
1261
+ profile to read, so an app cannot look somebody up. It is null for anyone without an account,
1262
+ which is the same thing `requires: "account"` is about. Use it to pre-fill a form rather than
1263
+ asking a signed-in person to type what the platform already knows.
1264
+
1265
+ In a test, `fakeViewer("visitor", { userId: "u_ada", role: "requester", name: "Ada", email: "ada@x.test" })`
1266
+ is what `viewerProfile` answers for that viewer; a fake viewer with no name or email answers null.
1267
+ No test ever reaches a database for it.
1268
+
1269
+ ## Roles — your app's own vocabulary
1270
+
1271
+ The platform's words are about a workspace. Your app's words are about your app. A help desk has
1272
+ requesters and agents; a library has readers and librarians; a clinic has patients and staff.
1273
+
1274
+ ```json
1275
+ "roles": {
1276
+ "vocabulary": ["requester", "agent", "supervisor"],
1277
+ "default": {
1278
+ "owner": "supervisor",
1279
+ "member-edit": "agent",
1280
+ "member-readonly": "agent",
1281
+ "visitor": "requester",
1282
+ "anonymous": "requester",
1283
+ "agent": "inherit"
1284
+ },
1285
+ "describe": {
1286
+ "requester": "raises tickets and sees their own",
1287
+ "agent": "answers every ticket; never the billing notes",
1288
+ "supervisor": "everything, including who handled what"
1289
+ }
1290
+ }
1291
+ ```
1292
+
1293
+ `default` maps the platform's kinds onto your words. Two sentinels are the platform's, not yours:
1294
+ `inherit` (an agent takes the role of whoever it acts for) and `none` (this app has no word for
1295
+ that kind of caller, so no rule can match them — the right answer when a stranger has no business
1296
+ here at all).
1297
+
1298
+ **The workspace owner can override it per person, per app.** In workspace sharing, an app that
1299
+ declares a vocabulary gets a picker showing your words and your descriptions. So one colleague is
1300
+ an `agent` in the help desk and another a `supervisor`, in the same workspace, and everyone
1301
+ outside is a `requester` whether or not they are signed in.
1302
+
1303
+ A role is a WORD, never a permission. It decides what your rules mean. Whether someone may write
1304
+ the workspace at all is still the platform's answer: a read-only collaborator you call `agent`
1305
+ still cannot write.
1306
+
1307
+ ## Access levels — nothing opens by default
1308
+
1309
+ Every op, route and task is `write` unless you say otherwise: the owner and editing members only.
1310
+
1311
+ ```json
1312
+ "ops": {
1313
+ "list-public-articles": { "access": "public" },
1314
+ "raise-ticket": { "access": "public", "requires": "account" },
1315
+ "my-tickets": { "access": "public", "requires": "account" },
1316
+ "close-ticket": {}
1317
+ },
1318
+ "routes": { "queue-stream": { "access": "read" } },
1319
+ "kickableTasks": { "escalate": { "access": "write" } }
1320
+ ```
1321
+
1322
+ | level | who reaches it |
1323
+ |---|---|
1324
+ | `write` (default) | the owner and members who may edit |
1325
+ | `read` | anyone who can SEE the workspace, including read-only members |
1326
+ | `public` | anyone holding the app's share link, with no workspace access at all |
1327
+ | `public` + `requires: "account"` | the same, but they must be signed in |
1328
+
1329
+ **`read` is not "outsiders".** It means workspace members. Someone on a share link is reached with
1330
+ `public`. Getting this wrong is the commonest mistake: an app that declares "my own records" as
1331
+ `read` refuses the very people it was written for.
1332
+
1333
+ **`requires: "account"` is the sign-in wall.** Without it an anonymous caller gets `forbidden`,
1334
+ which a UI can only render as an error. With it they get `login-required` and a way back, and
1335
+ `useSignInWall()` turns it into a wall:
1336
+
1337
+ ```tsx
1338
+ const wall = useSignInWall();
1339
+ try { await op("raise-ticket", args); }
1340
+ catch (e) { wall.raise(e); } // login-required → the wall; anything else rethrows
1341
+ ```
1342
+
1343
+ Every `public` door is listed on the install card, by name, before anyone installs your app.
1344
+
1345
+ ## What a refusal looks like
1346
+
1347
+ One shape everywhere, with a code your UI can act on:
1348
+
1349
+ | code | status | means |
1350
+ |---|---|---|
1351
+ | `forbidden` | 403 | the rules say no, and signing in would not change it |
1352
+ | `login-required` | 401 | sign in and try again; `signInPath` says where |
1353
+ | `not-bound` | 409 | a slot this app needs is not filled yet |
1354
+ | `rate-limited` | 429 | too many calls at a public door |
1355
+ | `invalid` | 400 | the call itself is malformed |
1356
+
1357
+ In a workbench the refusal also carries `detail`, written for YOU: which rule refused and what to
1358
+ change. It is never returned in production, and never contains the caller's data.
1359
+
1360
+ ## Seeing it before anyone installs
1361
+
1362
+ A workbench has **VIEW AS**: a switcher for the platform's kinds — owner, member, read-only
1363
+ member, two different visitors, anonymous, agent. Your app resolves each one through your own
1364
+ `roles`. Two visitors on purpose: the question worth asking is not "does a person see their own
1365
+ record" but "does the OTHER one see it".
1366
+
1367
+ `look_at_app` takes the same personas, so you can photograph the screen each of them gets — the
1368
+ one screen an author can otherwise never see, because the author is always the owner.
1369
+
1370
+
1371
+
1372
+ ==============================================================================
1373
+ # 14 · Your own tables
1374
+
1375
+ The fold (docs/03) is for what is shared, small and worth scrubbing. Records that belong to one
1376
+ person, that grow without limit, and that must never revert because somebody scrubbed a timeline
1377
+ are none of those. Those go in your app's own tables.
1378
+
1379
+ You declare them. The platform generates the schema, the typings and the rules, applies the
1380
+ migration on install, and scopes every query. You never write a migration, an ORM, or a
1381
+ `WHERE userId = …`.
1382
+
1383
+ ## Declaring
1384
+
1385
+ ```json
1386
+ "db": {
1387
+ "Article": {
1388
+ "fields": { "title": "string", "body": "text", "published": "boolean=true" },
1389
+ "unique": [["title"]],
1390
+ "indexes": [["published"]],
1391
+ "rules": { "read": ["anyone"], "write": ["supervisor"] }
1392
+ },
1393
+ "Ticket": {
1394
+ "owner": "creator",
1395
+ "fields": { "status": "string=open", "subject": "string", "detail": "text", "article": "ref:Article?" },
1396
+ "indexes": [["status"], ["createdAt"]],
1397
+ "rules": {
1398
+ "read": ["creator", "agent", "supervisor"],
1399
+ "create": { "roles": ["requester", "agent"], "requires": "account" },
1400
+ "update": { "roles": ["agent", "supervisor"], "creatorMay": ["detail"] },
1401
+ "delete": ["supervisor"]
1402
+ }
1403
+ },
1404
+ "ContactDetails": {
1405
+ "scope": "user",
1406
+ "sealed": ["phone"],
1407
+ "fields": { "label": "string", "phone": "string" }
1408
+ }
1409
+ }
1410
+ ```
1411
+
1412
+ **Field types.** `string`, `text`, `int`, `float`, `boolean`, `datetime`, `json`, `ref:Model`.
1413
+ A trailing `?` is optional; `=value` is a default; `[]` is a list. Platform columns (`id`,
1414
+ `workspaceId`, `nodeId`, `ownerId`, `createdBy`, `createdAt`, `updatedAt`, `deletedAt`) are added
1415
+ for you and may not be declared.
1416
+
1417
+ **Scope** decides what "this app's rows" means:
1418
+
1419
+ | scope | rows belong to | use it for |
1420
+ |---|---|---|
1421
+ | `instance` (default) | ONE app instance | this help desk's tickets |
1422
+ | `workspace` | every instance in the workspace | something shared between apps |
1423
+ | `user` | one person, across every instance | contact details that follow them |
1424
+
1425
+ **`owner: "creator"`** makes each row belong to whoever made it, which is what `creator` in a rule
1426
+ matches against. It matches every id that person has acted under, so a record created before
1427
+ signing in is still theirs afterwards.
1428
+
1429
+ **`sealed`** fields are encrypted at rest and readable only by the row's owner (and your app's own
1430
+ internal code, which is how a background job can still use one). Anyone else reading a sealed
1431
+ field gets null, not ciphertext.
1432
+
1433
+ ## Rules
1434
+
1435
+ A rule names PRINCIPALS: `"anyone"`, `"creator"`, a role from your vocabulary, or `"apps"` (apps
1436
+ bound to yours — see docs/16). The long form adds `requires: "account"` and `creatorMay`, which
1437
+ lists the fields a row's creator may change even when they may not otherwise update it.
1438
+
1439
+ Rules are compiled once, at build time, and the SAME compiled artefact drives the in-memory client
1440
+ your tests use, the Prisma client production uses, and the workbench. A rule that names a role
1441
+ your app does not declare fails the build, with the key path.
1442
+
1443
+ ## Using
1444
+
1445
+ ```ts
1446
+ import { pluginDb } from "esoul-sdk/server";
1447
+ import type { HelpdeskDb } from "./.esoul/db"; // generated from your manifest
1448
+
1449
+ async function myTickets(ctx: PluginOpContext) {
1450
+ const db = await pluginDb<HelpdeskDb>(ctx);
1451
+ return db.ticket.findMany({ orderBy: { createdAt: "desc" }, take: 50 });
1452
+ }
1453
+ ```
1454
+
1455
+ That is the whole access story. The same call returns one person's own tickets and an agent's
1456
+ whole queue, because `ctx.viewer` is already in the client. There is no argument you could pass to
1457
+ read someone else's rows: `workspaceId`, `nodeId` and `ownerId` are injected, and naming one in a
1458
+ query is refused as `invalid` rather than quietly overridden.
1459
+
1460
+ Ordering and filtering are limited to the fields you indexed — an unindexed `orderBy` is a compile
1461
+ error in your editor, not a slow query in production.
1462
+
1463
+ ### The whole surface
1464
+
1465
+ Your generated type is the reference (open `.esoul/db.d.ts`), and this is all of it:
1466
+
1467
+ | | |
1468
+ |---|---|
1469
+ | read | `findMany` · `findFirst` · `findUnique({ where: { id } })` · `count` |
1470
+ | summarise | `aggregate({ where?, _count, _sum, _avg, _min, _max })` · `groupBy({ by, … })` |
1471
+ | write | `create` · `createMany` · `update({ where: { id }, data })` · `updateMany` · `upsert` · `delete` · `deleteMany` |
1472
+ | together | `$transaction(fn)` |
1473
+
1474
+ `findMany` takes `where`, `orderBy`, `take`, `skip`, `cursor: { id }`. Every `where` — including
1475
+ an `aggregate`'s — obeys the index wall above: a field you did not index is not a question, and
1476
+ asking it is refused as `invalid` rather than answered slowly. **Count and sum in the database**
1477
+ rather than paging rows to add them up in JavaScript; `groupBy` still returns only the rows this
1478
+ viewer may read, so a customer's own total is their own and an aggregate is never a way around a
1479
+ rule.
1480
+
1481
+ ### Transactions
1482
+
1483
+ `$transaction` gives you the one guarantee a pair of writes cannot give itself: either both
1484
+ happened, or neither did.
1485
+
1486
+ ```ts
1487
+ const order = await db.$transaction(async (tx) => {
1488
+ const written = await tx.order.create({ data: { totalCents, shipTo } });
1489
+ for (const l of lines) await tx.orderLine.create({ data: { order: written.id, ...l } });
1490
+ await tx.cartLine.deleteMany(); // the basket that made it
1491
+ return written;
1492
+ });
1493
+ ```
1494
+
1495
+ Every gap between two writes is a real state somebody's session can end in. An order committed
1496
+ and a basket emptied a moment later is not a detail: the moment in between is "paid for, still in
1497
+ the basket", which is how a person buys the same thing twice. Put the writes that must be true
1498
+ together in one call, and let a throw undo all of them.
1499
+
1500
+ Rules still apply inside, per statement, for the same viewer — a transaction is atomicity, never
1501
+ elevation.
1502
+
1503
+ **What a transaction cannot hold.** A call into ANOTHER app (`ctx.apps.<slot>.call(…)`, an email,
1504
+ a payment) is outside it by construction: the other app's writes are its own. For those, hold →
1505
+ write → and **release on failure**, yourself:
1506
+
1507
+ ```ts
1508
+ const held = [];
1509
+ try {
1510
+ for (const l of lines) { await stock.call("reserve_stock", l); held.push(l); }
1511
+ return await db.$transaction(…);
1512
+ } catch (err) {
1513
+ for (const h of held) await stock.call("release_stock", h); // never throws out of here
1514
+ throw err;
1515
+ }
1516
+ ```
1517
+
1518
+ A reservation no order ever used is worse than a refusal: nothing marks it stale, so the shelf
1519
+ reads empty while the goods sit there.
1520
+
1521
+ ## Indexes for a catalogue
1522
+
1523
+ A plain group is a btree: equality, ranges, ordering. Two other kinds exist because a real
1524
+ catalogue asks two questions a btree cannot answer.
1525
+
1526
+ ```json
1527
+ "indexes": [
1528
+ ["active", "createdAt"],
1529
+ { "fields": ["tags"], "kind": "contains" },
1530
+ { "fields": ["title"], "kind": "text" }
1531
+ ]
1532
+ ```
1533
+
1534
+ | kind | the field | what it lets you ask |
1535
+ |---|---|---|
1536
+ | `btree` (a plain group) | any | `where: { active: true }`, `orderBy: { createdAt: "desc" }` |
1537
+ | `contains` | a LIST | `where: { tags: { has: "gifts" } }` |
1538
+ | `text` | a string or text | `where: { title: { contains: "grey" } }` — **regardless of case** |
1539
+
1540
+ ```ts
1541
+ // A department and a search term, both as a WHERE — with a cursor, so the
1542
+ // hundredth page costs what the first one did.
1543
+ db.product.findMany({
1544
+ where: { active: true, tags: { has: dept }, title: { contains: term } },
1545
+ orderBy: { createdAt: "asc" },
1546
+ take: 24,
1547
+ cursor: after ? { id: after } : undefined,
1548
+ });
1549
+ ```
1550
+
1551
+ `contains` ignores case in both clients (`ILIKE`, which a trigram index still serves), because a
1552
+ search box that finds "Earl Grey" and not "earl grey" is broken in the quietest possible way — the
1553
+ shopper just reads an empty shelf.
1554
+
1555
+ **A list takes one question and a scalar takes the others.** `{ tags: { has: … } }` on a list,
1556
+ never a bare value and never `contains`; `{ title: { contains: … } }` on text, never `has`. Asking
1557
+ the wrong one is refused as `invalid` with the right one named, because an empty result would look
1558
+ like an empty shelf. (A refusal's `message` is the code; the sentence written for you is on
1559
+ `err.detail`.)
1560
+
1561
+ Both of the new kinds become GIN indexes, and neither is led by the scope column — a GIN index
1562
+ cannot be, so the database combines it with the scope index instead. That is the right plan, and
1563
+ it is why `contains` and `text` cover exactly one field each.
1564
+
1565
+ **Why this matters more than it looks.** Without them, a shop with 100 000 products can only
1566
+ return a page and filter it in the browser, so a department with nothing on the first page looks
1567
+ empty. The declaration is the whole difference between a catalogue and a list.
1568
+
1569
+ ## Migrations
1570
+
1571
+ On install the platform computes what your declaration means for the live database and applies it
1572
+ in one transaction, recording it in a ledger. **Additive only.** Adding a table, a column or an
1573
+ index is applied; changing a column's type, or adding a required column with no default, is
1574
+ REFUSED with the column named, and the install does not proceed. A field you removed keeps its
1575
+ column and its data — dropping it is a separate, deliberate act.
1576
+
1577
+ Uninstalling never drops a table. The rows are somebody's records.
1578
+
1579
+ ## Testing
1580
+
1581
+ ```ts
1582
+ import { fakeViewer, memoryDb, runOp } from "esoul-sdk/testing";
1583
+
1584
+ const db = memoryDb(manifest); // your rules, compiled from your own plugin.json
1585
+ const ada = fakeViewer("visitor", { userId: "u_ada", role: "requester" });
1586
+ const lin = fakeViewer("visitor", { userId: "u_lin", role: "requester" });
1587
+
1588
+ await runOp(pluginServer, "raise-ticket", { viewer: ada, args: TICKET, db: db.as(ada) });
1589
+ expect(await db.as(lin).ticket.count()).toBe(0);
1590
+ ```
1591
+
1592
+ The in-memory client and the production one are proven equal by a differential test on every
1593
+ build, so a rule you prove here is a rule the database keeps.
1594
+
1595
+
1596
+
1597
+ ==============================================================================
1598
+ # 15 · Realtime, and who hears it
1599
+
1600
+ Your app can push. A screen that is already open moves without anyone asking it to, which is the
1601
+ difference between an application and a form.
1602
+
1603
+ Two declarations, both needed. The manifest says WHO hears each topic; the schema says what the
1604
+ topics are and what rides on them.
1605
+
1606
+ ```json
1607
+ "channel": {
1608
+ "topics": {
1609
+ "notice": { "description": "something everyone watching should see" },
1610
+ "ticket-status": { "audience": "viewer", "mayAddress": ["agent", "supervisor"] },
1611
+ "new-ticket": { "audience": "role:agent", "mayAddress": ["requester", "agent", "supervisor"] }
1612
+ }
1613
+ }
1614
+ ```
1615
+
1616
+ ```ts
1617
+ export const channel = definePluginChannel({
1618
+ applicationType: APP_TYPE,
1619
+ topics: {
1620
+ notice: { schema: z.object({ what: z.string() }) },
1621
+ "ticket-status": { schema: z.object({ ticketId: z.string(), status: z.string() }) },
1622
+ "new-ticket": { schema: z.object({ ticketId: z.string(), subject: z.string() }) },
1623
+ },
1624
+ });
1625
+ ```
1626
+
1627
+ ## Audiences
1628
+
1629
+ | `audience` | the channel |
1630
+ |---|---|
1631
+ | `all` (default) | one per instance — anyone watching the app |
1632
+ | `viewer` | one per person — "your ticket was answered" |
1633
+ | `role:<name>` | one per role — the queue everyone on duty watches |
1634
+
1635
+ The platform mints a token PER CHANNEL, and the browser asks for a KIND of channel, never for an
1636
+ identifier. The server fills in whose channel that is from the viewer it resolved. So one person
1637
+ cannot ask for another's channel: the id is not theirs to supply, and a role's channel is simply
1638
+ not minted for someone outside that role. Nothing is filtered on arrival, because nothing wrong
1639
+ ever arrives.
1640
+
1641
+ An app that declares no audiences keeps the single channel it always had.
1642
+
1643
+ ## Sending
1644
+
1645
+ ```ts
1646
+ await ctx.notify("ticket-status", { ticketId, status }); // the CALLER's own channel
1647
+ await ctx.notify("ticket-status", { ticketId, status }, { to: { viewerIds: [row.ownerId] } });
1648
+ await ctx.notify("new-ticket", { ticketId, subject }, { to: { role: "agent" } });
1649
+ ```
1650
+
1651
+ Unaddressed, a message goes where the topic says. Addressed, **aiming is a separate permission
1652
+ from hearing**: a caller may address only ITSELF unless the topic's `mayAddress` names its role.
1653
+ Your app's own tasks always may, which is why a follow-up task can tell the person who waited.
1654
+
1655
+ Two traps worth naming. Both cost the reference app a live drive, and both are easy to repeat:
1656
+
1657
+ - **Everyone who may DO the thing must be allowed to announce it.** An app that let two roles
1658
+ create a record but only one of them announce it refused the others their own action — by their
1659
+ own announcement. List every role your `create` rule allows.
1660
+ - **A courtesy must not undo a completed write.** The record is already in the table when you
1661
+ announce it. Catch a failed notify and report it; do not let it throw out of the op, or a person
1662
+ is told "forbidden" about something that exists and tries again.
1663
+
1664
+ ## Listening
1665
+
1666
+ ```tsx
1667
+ const live = usePluginRealtime<{ ticketId?: string; status?: string }>({
1668
+ channel,
1669
+ workspaceId: state.workspaceId,
1670
+ nodeId: state.nodeId,
1671
+ topics: channel.topicNames,
1672
+ enabled: !!state.nodeId && viewer.signedIn,
1673
+ });
1674
+
1675
+ useEffect(() => {
1676
+ if (live.latestData?.topic === "ticket-status") reload();
1677
+ }, [live.latestData, reload]);
1678
+ ```
1679
+
1680
+ **Treat a message as a nudge, not as data.** Re-read through your op, so the rules decide what
1681
+ comes back. A payload that carried the row would be a second way to learn about it, and only one
1682
+ of them is checked.
1683
+
1684
+ In a workbench the preview shows a persona only what their channels would carry, so VIEW AS tells
1685
+ you the truth: switch to the other visitor and the message is not there.
1686
+
1687
+
1688
+
1689
+ ==============================================================================
1690
+ # 16 · Bindings — leaning on another app
1691
+
1692
+ An app is an island until it can use another one. A booking app that wants real room availability
1693
+ either keeps its own counts and guesses, or names one particular rooms app and is married to it.
1694
+ Neither is what a person means by "use my room list for this".
1695
+
1696
+ So you declare a SLOT and the CONTRACT that must stand in it. The owner picks which app fills it.
1697
+
1698
+ ## Declaring
1699
+
1700
+ The consumer:
1701
+
1702
+ ```json
1703
+ "uses": { "rooms": { "contract": "rooms/v1", "label": "Rooms", "optional": true } }
1704
+ ```
1705
+
1706
+ The provider:
1707
+
1708
+ ```json
1709
+ "provides": {
1710
+ "rooms/v1": { "tools": ["hold_room", "release_room"], "events": ["room_freed"], "models": ["Room"] }
1711
+ }
1712
+ ```
1713
+
1714
+ A consumer must also fold the binding event, because the owner's choice lives on your app's own
1715
+ timeline — a binding is CONSENT, and consent that is only a column cannot be scrubbed, explained
1716
+ or audited:
1717
+
1718
+ ```ts
1719
+ export const bindingSetEvent = defineBindingEvent<BookingData>({ applicationType: APP_TYPE, slots: ["rooms"] });
1720
+ // …and put it in the schema's `events`.
1721
+ ```
1722
+
1723
+ The build refuses an app that declares `uses` without it.
1724
+
1725
+ ## What the platform checks, and when
1726
+
1727
+ At BIND time, once, loudly — never at call time in front of somebody using the app:
1728
+
1729
+ - The provider must claim the same contract NAME at a version at least as high. **Versions grow by
1730
+ adding**, so a `rooms/v2` provider fills a `rooms/v1` slot; the reverse is refused, because v1
1731
+ never heard of what v2 added. That rule is what lets your app keep working while its provider
1732
+ moves on.
1733
+ - The provider must really HAVE what it claims: the tools it actually mints, the events its schema
1734
+ declares, the tables its `db` generates. A manifest is a claim, and a claim that is short is
1735
+ refused with the missing part named.
1736
+
1737
+ ## Using
1738
+
1739
+ ```ts
1740
+ const rooms = ctx.apps?.rooms; // absent when nothing is bound
1741
+ if (rooms) {
1742
+ const held = await rooms.call("hold_room", { roomId, from, to });
1743
+ if (!held.ok) throw new Error(`cannot take this booking: ${held.text}`);
1744
+ }
1745
+ ```
1746
+
1747
+ - **An unfilled slot is ABSENT**, not present and broken, so `ctx.apps.rooms?` reads as the
1748
+ question it is. An optional slot means your app still works without one; a required slot that is
1749
+ empty refuses at the seam with `not-bound`, which a UI should render as "connect one" rather
1750
+ than as an error.
1751
+ - **A slot reaches the CONTRACT's tools, not the provider's toolkit.** A consumer bound through
1752
+ `rooms/v1` may hold a room and release it; it may not call the rooms app's `retire_room`, though
1753
+ it has one. The binding is consent to a contract.
1754
+ - **The caller travels.** The provider's rules meet the actual person, so a visitor reaching the
1755
+ rooms app through your app sees what a visitor may see.
1756
+ - **Writes never cross directly.** The provider's own tools and ops are the only writers of its
1757
+ tables, which is how a ledger keeps refusing to oversell no matter who asked.
1758
+
1759
+ ## Being a good provider
1760
+
1761
+ A provider's whole value is usually one refusal: you cannot hold what is not free. Keep it in ONE
1762
+ place — the app's own server — so it holds however the hold was asked for: through a binding, a
1763
+ tool, or a person working in the app itself. That is what makes an app worth binding to. An app
1764
+ that counted the same thing in its own fold could not make the promise, because two people can
1765
+ read the same fold in the same instant.
1766
+
1767
+ Say no in your own words, too. A refusal that reads `cannot hold 2: only 1 free (1 free, 1 already
1768
+ held)` reaches the consumer, and the consumer can put it in front of a person instead of a shrug.