feat(apps): vibecode an app inside a drive — UI, schema and logic as data - #1307
Open
joepio wants to merge 115 commits into
Open
feat(apps): vibecode an app inside a drive — UI, schema and logic as data#1307joepio wants to merge 115 commits into
joepio wants to merge 115 commits into
Conversation
A collection whose first page fetch ran before the socket was up resolved empty and stayed empty. The local DB answers "no-db" when its index holds nothing for this parent, and the bounded wait for the connection that follows was reserved for stores with no OPFS at all — so the common case fell off the end of `fetchPage`: no request, no retry, and `_waitForReady` resolving anyway, leaving the UI on "no members" with neither a loader nor an error. Signing in loses that race by design, since `serverConnected` only flips after AUTH_OK. On a drive addressed by its server URL there is also no drive SUB and no version-vector sync to repair it later, so the one query is the only chance: staging came up with an empty sidebar when signed in and a full one when signed out. Wait for the connection whether or not OPFS is present. A genuinely offline session pays one bounded wait and still ends up empty, which is the truth then.
A Commit imported from another server carries a `<server>/commits/<sig>` subject, which slipped past the `did:ad:commit:` guard that keeps commit-detail resources out of the outbox. Once queued it never left: the server answers "Commits cannot be edited." on every attempt, that message matched neither the terminal nor the blocking classifier, and the entry re-POSTed on backoff for the life of the session. Recognize both subject shapes in one place, refuse the enqueue at the source, and treat the server's refusal as terminal so an entry already persisted in a user's outbox drains away. Dropping it loses nothing — a Commit is whatever was signed, and no local edit to one could ever have applied — so it goes out through the quiet path rather than as an error the user is asked to act on.
Views, automations, importers and validators were four layers with four
vocabularies, each inventing its own manifest, permission model and
lifecycle. Collapse them onto one artifact: a plugin declares when it runs,
what it may touch, and exports `view` (interactive, scoped grant) or `run`
(one-shot, returns a reviewable verdict).
`run` returning `{intents, problems, cursor}` instead of writing is the join.
A validator returns problems, an importer returns intents, an automation
returns both, a connector adds a cursor — so validation, preview, approval,
commit and undo become one host code path, and the sandbox has nothing to
contain.
Placement (Worker / iframe / Wasmtime) is derived by the host from trigger
and capabilities, so neither authors nor LLMs pick a runtime. TypeScript is
the authoring language; WASM is the server-side containment layer.
Resolves the local-first question in #1193: a signed, authorized commit is
valid, so `commit:before` is admission policy at the authoring edge and a
lint everywhere else. No client needs to run server WASM to agree on what
exists, which removes the need to ship jco everywhere.
Also removes `on_resource_get`: it defeats caching and makes nodes serve
different content for the same subject.
First piece of the one-plugin-model work. `run` returns
`{ intents, problems, cursor }` and never touches the store, so the host owns
validation, preview, approval and commit for importers, automations and
validators alike.
`parseVerdict` normalizes whatever untrusted plugin code actually returned.
It never throws and never trusts the shape: malformed entries are dropped and
reported as problems, so a broken plugin fails visibly in the preview rather
than quietly proposing less than it meant to. Over the intent limit it refuses
the whole batch instead of planning a prefix — a half-applied import that looks
complete is the worse failure.
`local:<localId>` references let one verdict describe a linked graph (an
imported contact pointing at an Organization the same run creates). References
resolve in either order; dangling ones drop the intent rather than minting a
subject for a resource nobody creates.
Two judgement calls worth naming: `undefined` values are dropped so an absent
optional column is not a malformed intent, and a plugin-reported problem
defaults to `error` so a validator is not downgraded by a typo in `severity`.
Moves the run contract into @tomic/lib and adds the execution half. The contract had to move: @tomic/plugin already depends on @tomic/lib, and the host needs the same types the guest does, so lib owns them and @tomic/plugin re-exports so authors still import from one package. `invokeRun` never throws. A plugin that blows up, rejects, returns a cycle or floods its output all come back as a Problem the preview can show, because "the import silently did nothing" is the failure worth designing against. Oversized output is refused outright rather than clipped — a clipped verdict is a wrong verdict, not a smaller one. The sandbox freezes `Date` to `trigger.at` and seeds `Math.random` from the input rather than forbidding either. Plugins get re-run constantly — on a fixture before a release is trusted, on a sample before the full input, and on the server after being authored in a browser — and a clock that moves between those runs turns every one of those checks into a coin flip. Explicit dates still parse, so only the ambient clock is pinned. Denied ambient globals throw a message naming the alternative instead of failing as `undefined is not a function`. `run` has no authority by design, so reaching for fetch is an authoring mistake and deserves an authoring error. The iframe CSP and the server sandbox remain the enforcing layers; this one is DX. Drops the now-empty test script from @tomic/plugin.
Host half of the runner. `runPlugin` spawns a sandbox, sends the source and input, and returns a verdict that is always safe to render: a crash, a hang, a sandbox that will not start, an unreadable reply and a late second reply all come back as a blocking problem rather than a rejected promise, so callers have one result screen instead of two. The sandbox is always terminated — on success, on failure, and on timeout. An infinite loop in generated code is a normal Tuesday, so the budget terminates rather than waits, and the verdict says the run was stopped and nothing was planned. Module evaluation is a blob URL and a real dynamic import, so `import` and top-level await work; generated code looks like ordinary TS output and failing on a stray `export` would be a poor first experience. The worker is its own tsup entry, matching client-db.worker, so hosts point a `new Worker(...)` at it instead of maintaining a parallel hand-written copy. Worker creation is injected, so the timeout, teardown and error paths are unit tested. The blob-import path itself needs a real browser and is not covered here; it gets exercised when the runner is wired into a host.
Running the built worker in a real browser showed the denial was doing
nothing: a plugin calling `fetch('https://evil.example')` reached the network,
and `indexedDB` came back as a live object. Worker globals live on
`WorkerGlobalScope.prototype`, not on `globalThis`, so looking only at own
property descriptors skipped every single one and reported success.
Shadow by defining an own property whenever the name is reachable, and look the
original up along the prototype chain so restore puts back an inherited global
by dropping the shadow rather than leaving `undefined` behind.
A global that cannot be shadowed is now reported instead of swallowed, and the
runner turns that into a warning on the verdict — a denial that quietly did not
apply is worse than none, because it reads as containment that is not there.
Verified in a browser: fetch, indexedDB and WebSocket all throw the authoring
error, `undeniable` comes back empty, `Date.now()` returns the trigger time,
and two runs over the same input draw the same random sequence.
Turns a verdict into a reviewable plan: mints subjects for creates, rewrites `local:` references onto them, and checks every intent against the schema up front. Letting commits fail naturally would mean approving 2,000 writes and learning on write 1,700 that a column maps to a property that does not exist, with 1,699 already applied. Blocking: a property that does not exist, a value of the wrong datatype, a target resource that is not there, and creates that parent each other — the last reported by name rather than left to spin, since the mint order can never resolve. Non-blocking, because they are honest outcomes of a mapping rather than mistakes: a create with no class, a remove of something already unset, and a write whose value the resource already has. All three stay visible in the preview instead of being dropped. A change carries `from` and `to` per property so the preview can render a diff rather than a list of writes, and each property is looked up once per plan however many intents use it. `PlanHost` is deliberately narrow and `PlanStore` is structural: the CLI and the eventual server-side runner need the same planner and neither has a browser Store.
…me plan A link to a resource this run also creates can be checked for free — its class is right there in the create intent — so a Person whose employer points at a Note is blocked before approval rather than after 1,700 writes. Links to resources that already exist are deliberately not verified. That means one fetch per link, and an import connecting 5,000 contacts to organizations would spend 5,000 round-trips in front of an approval prompt nobody has agreed to yet. Those stay a commit-time concern; the code says so rather than leaving the gap to be discovered.
…hange The planner's minted subjects turn out to be placeholders: a store may mint the real subject itself, since a DID derived from a genesis certificate cannot be guessed ahead of time. So a create reports the subject it actually got, and every later reference is rewritten onto it — otherwise an imported contact would link to a subject that never existed. Applying reorders creates ahead of everything else, parents before children. The plan keeps intent order so the preview reads the way the run was written; only applying needs dependency order. A blocked plan is refused outright rather than partially written, and a failure stops the run by default — a failed create means everything linking to it would point at nothing, and half a linked graph is harder to reason about than a run that stopped. Imports that would rather take what they can get pass `continueOnError`. Every change gets an outcome, including the ones never attempted, so "it imported 1,700 of 2,000" is a thing the UI can say precisely.
Adds `applyHostFromStore` and an integration test that runs a verdict through parse, plan and apply against a real Store with a DID agent, then inspects the commits it produced. This confirms the assumption the applier was built on: for a DID agent the store mints the subject itself from a genesis certificate, so the planner's subject really is only a placeholder, and the child in the test lands under the DID the folder actually got rather than the one that was planned. Every other test in this chain runs against fakes, so nothing else would have caught a store contract that differed. Also confirms a blocked plan writes nothing: no commit reaches the wire. Measured while here: 20 creates produce exactly 20 commits, so there is no commit amplification in this path. That says nothing about OPFS writes — the test store has no ClientDb attached, and the write-amplification work is about persistence, not commits.
`ensureSchema` makes a spec written in TypeScript real in a drive's ontology, reusing anything already there so a plugin's first run and its hundredth take the same path. Code-first rather than baked into the core ontology: the shape of a run will keep moving while triggers and preview are built, and core-ontology churn is paid for by every server. `recordRun` writes what a run actually did — trigger, status, every problem, and the outcome of every planned change. That record is what makes an LLM-written plugin trustworthy to someone who did not write it, and the only way to answer "why does this resource say that" a week later. A blocked run is recorded too. A refusal to write is exactly what someone needs to find later, and leaving it unlogged makes "it silently did nothing" indistinguishable from "it never ran". The cursor is only persisted once something was actually applied — saving it for a run that wrote nothing would tell the next run to skip work never done. Carrying it required threading it from the verdict through the plan, which was simply missing. `pluginRunSchema` is a function, not a const: `index.ts` is in an import cycle with `parse.ts`, so a module-level `Datatype.STRING` reads as undefined. That broke three unrelated suites before it was caught. Known gap: two runs racing on a drive with no schema would both create one. Rare, visibly wrong when it happens, and the fix belongs with a general schema registry rather than here.
Closes the manual-trigger path: a Run action on a plugin resource executes it in the sandbox, plans what it proposed against the schema, and shows the diff. Nothing is written until the dialog is approved. The run itself happens before the dialog renders, which is the point — a run holds no authority, so executing it needs no consent. The dialog is the boundary around writing, not around running. `findSchema` looks a code-first spec up without creating it, because `available()` on an action is synchronous and opening a context menu must not bring plugin classes into existence as a side effect. The action appears from the render after the lookup resolves. Cancelling a blocked run still records it. A refusal that leaves no trace is indistinguishable from a plugin that never ran, which is the confusion this whole path exists to remove. Two constraints from the project's CLAUDE.md shaped the component: the React Compiler cannot compile try/catch/finally inside a component, so applying is a module-level helper returning a result rather than throwing; and the effect depends on a subject string rather than the Resource proxy, whose identity churns.
The Run action never appeared. `available()` read the drive's plugin class from a module cache that a background lookup filled in later — but filling a cache re-renders nothing, so the menu kept recomputing from the same miss no matter how many times it was opened. Async data has to flow through state. `usePluginClass` resolves it with useEffect and the menu passes the result on the action context, so the action appears as soon as the lookup lands. That also removes the "shows up from the next render" wart the previous approach shipped with. Verified against a real server: the action appears on a plugin resource, the dialog previews a create with its property values, both the plugin's own warning and the planner's warning are shown, applying writes the resource, and the run record stores status, trigger, frozen start time, both problems and the outcome — including the planned `_new:` subject alongside the DID the store actually minted.
…ookup fresh Until now a plugin could only be made from the console, which made the whole Run path unreachable. `New plugin` creates one under the current resource, bringing the drive's plugin schema into existence if this is the first, and opens it. The starter source doubles as the contract's documentation — the shape of `run`, what the input carries, and that returning intents is how a plugin writes. It is the first thing an LLM asked to change a plugin will read, so it is written to be copied. Creating that first plugin exposed a stale lookup: the menu had already resolved "this drive has no plugin class" and nothing told it otherwise, so Run stayed hidden until a reload. `usePluginClass` now subscribes to the drive's ontology and re-resolves when it changes, which also covers a schema arriving over sync. Verified that an ontology subscription does fire when a class is added. Verified end to end on a fresh drive and agent: New plugin creates the schema and the resource, Run previews the starter plugin's create, and applying writes it.
An imported contact whose employer points at an Organization the same run creates is not that Organization's child, so ordering by `parent` alone could write the contact first — and then the link was written as the planner's placeholder subject, which never exists. That is silent data corruption, and a linked import is precisely the case that produces it. It survived every existing test because the integration test links a child to its parent, and the app demo created one resource. Creates are now ordered after every create they refer to, through parents and through property values nested in arrays and objects. Anything still unresolved at write time — reachable when two creates name each other, where no order satisfies both — fails that change with a message naming the references, rather than writing a link to nothing.
…ip at a time Applying was strictly sequential, so an import cost rows × latency: two thousand rows on a 30ms link is a minute spent almost entirely waiting. On a 5ms stand-in, 100 creates took 564ms with one write ever in flight. Changes now run in waves of chains that cannot affect each other. Creates form one wave per dependency level, so a create never overlaps anything it refers to; everything else follows in a single wave, chained per subject so two writes to one resource stay in order. Up to `concurrency` chains run at once, 8 by default. Same measurement after: 74ms, peak concurrency 8. Stop-on-error keeps its meaning — a failure stops new work from launching, writes already in flight finish and are recorded — and outcomes are still reported once each in plan order regardless of the order they completed in.
The first commit on this branch ran `git commit` with another session's deletion of `.env.development` already staged in the shared checkout, so it was swallowed into a planning-docs commit that had nothing to do with it. Restoring the file here rather than rewriting history: the branch is shared ground and a rebase would be worse than an extra commit. Whoever meant to delete it still means to — this only takes it off a branch it never belonged on.
…t ran Run records existed but were only findable by navigating to them by hand, and the properties that matter — problems and outcomes — render as raw JSON in the default property list. So the log was written and unread. A plugin's page now lists its runs, newest first: status, when, how many changes applied and failed, and on expand every problem and the outcome of every change, each linking to the resource it touched. This is the part that makes an LLM-written plugin trustworthy to someone who did not write it. They cannot read the source with any confidence that it does what it claims; they can read what it did. Children are not materialized on the parent resource, so the runs come from a collection query on `parent` rather than a `subresources` value that is always empty. Anything under a plugin without a run status — the resources it created with itself as parent — is skipped.
The run log is a collection query, and a query does not know about a resource it has never seen. So applying a run left the log showing "This plugin has not run yet" until a reload — exactly the moment someone wants to see what just happened. Manual testing missed it because every check reloaded the page between runs. The e2e test caught it on the first run. Applying a run, or recording a blocked one, now tells anything showing that plugin's runs to re-query.
Everything below the UI has unit tests and the whole chain has an integration test against a real Store, but the app path was only ever verified by hand — and four bugs on this branch were caught by a browser and nothing else. Two specs, both through the real UI: creating a plugin brings the drive's schema into existence and Run appears without a reload; running proposes a create and writes nothing until approved, after which the log names what it did; and a run targeting a resource that does not exist is blocked, cannot be applied, and is still recorded when dismissed. Nothing waits on a timeout — each step waits for the signal it actually needs (the menu item, the dialog, the log entry). Ran three times clean. The blocked-run spec reaches for `window.store` to swap the plugin's source, because the source property is drive-local and has no fixed subject. It finds the property by its value rather than importing app modules, so it does not couple to internal paths.
A3 was named as gating four roadmap items and designed nowhere. This is the design, grounded in what the runtime already does rather than in the abstract. Three findings shape it. `PluginMeta` already keeps a plugin's `agent_secret` in its own redb table rather than as a resource, so it is never committed, synced or indexed — that is the right shape and secrets extend it. The current `Network` permission maps to `inherit_network()`, which hands a plugin the host's entire network including loopback, the private ranges, and any cloud metadata endpoint the host can reach. And `get_resource` fetches foreign subjects through a second path gated on the same flag, so an allowlist that only covered a new HTTP import would leave it open. The threat worth designing against is not a hostile author. It is a plugin an LLM wrote, from a prompt, over data nobody audited. So: plugins get `secret:<name>` handles and never a value, no endpoint returns one, and substitution happens only in header values for an origin that secret is scoped to — a handle in a URL is an error, because credentials in URLs end up in logs by design. One egress function covers every outbound request, with an exact-origin allowlist checked after DNS resolution and again after each redirect; `inherit_network()` goes away and `PermissionType::Network` is removed rather than aliased, so ambient access stops being expressible. Rollout puts the egress guard first: it ships no features and closes the current hole.
A plugin declaring the `network` permission got `inherit_network()`, which is `socket_addr_check(|_, _| true)` — the host's entire network. That includes loopback, the private ranges, and 169.254.169.254, so a plugin computing a URL from data nobody audited could read the machine it runs on. Both ways out are now checked against the same rules. The guest's own sockets go through `socket_addr_check`, and the host's fetch of a foreign subject — which never touches a guest socket and was gated on the same permission — goes through `refuse_url`. The check is on the resolved address, which is the only place it can work: a hostname resolving to 169.254.169.254 is the whole attack, and inspecting the hostname catches none of it. Every resolved address must pass, so a name answering with one public and one private address is not usable by picking the convenient answer. Ordinary public addresses are untouched, so the example plugin's Discord webhook keeps working. This is a denylist of ranges, not an allowlist of hosts: the hostname is not available at the socket layer, and exact-origin allowlisting belongs at the `http-request` boundary in planning/plugin-secrets.md. This closes the range that gets a server owned. `refuse_url` resolves and then hands the URL to a client that resolves again, so it narrows the window rather than closing it; pinning the checked address into the connection needs a client we configure, and the comment says so.
Credentials a plugin may spend but never read, kept in their own tree beside `PluginMeta` rather than as resources — a resource is committed, synced, indexed and rendered, and a credential must be none of those. Nothing has to strip them from sync because they were never in it. There is no accessor that returns a value. `use_plugin_secret` hands it to a closure and never out of one, so no endpoint can serve a secret by accident, and `PluginSecretInfo` — the only shape a caller ever sees — has no field to put one in. Origins match exactly, scheme and port included. No wildcards: `*.example.com` is one typo from `evil-example.com`, and a credential is the wrong thing to be generous with. A secret with no origins goes nowhere rather than everywhere. Uses are counted as well as timestamped, because "used 0 times in 90 days" is what makes revoking easy and a date alone leaves you guessing. A refused origin is not counted as a use. Scoped per plugin, so revoking one is about one plugin. Plaintext at rest, as `agent_secret` already is — a deliberate deferral to planning/encryption.md, noted in the module rather than left to be discovered. Two things the tests caught: a redb table is not created until first written, so reading secrets on a store that never had one errored instead of reporting absence; and the sled batch needs applying outside the main transaction, which sled caps at nine trees — created-but-never-applied would have dropped writes silently.
`/plugin-secret` takes POST, GET and DELETE. Write-only by construction rather than by discipline: no route can return a value because `Db` has no method that produces one. GET describes what exists — name, origins, when and how often used — which is what someone needs to decide whether to revoke, and nothing more. Authorization is `check_write` on the plugin resource. A signature identifies an agent; it does not say that agent may touch this drive's plugins, and treating "the request was signed" as permission is how an endpoint ends up open to anyone who can make a keypair. Verified against a running server: an unauthenticated write to an existing resource is refused with 401 by the rights check, not merely by a missing lookup. Origins are normalized to scheme, host and port on the way in, so what is stored is exactly what is compared later and a trailing slash cannot make a secret quietly unusable. A URL carrying credentials is refused — those belong in the secret, not in the origin. A secret with no origins is refused outright, since it could never be spent. Keys are `drive \0 plugin \0 name` rather than msgpack, because listing a plugin's secrets is a prefix scan and a msgpack struct encodes its field count in the first byte — a two-field prefix is not a prefix of a three-field key. The listing test covers the neighbours that would collide if it were: another plugin on the same drive, and the same plugin name on another drive.
Adds `fetch` to the plugin ABI. A plugin has no sockets of its own, so this is the only way out and the host decides whether it opens. Four things hold before a byte leaves: the manifest declares the origin, the URL resolves to somewhere on the public internet, no secret handle appears where it would be logged, and every handle in a header resolves to a secret this plugin owns, scoped to this origin. Credentials go in a header as `secret:<name>`. The host substitutes at the boundary, so the plugin printing its own headers shows `secret:notion` rather than a token. A handle in the URL or the body is an error rather than a substitution — a credential in a URL is written to access logs, proxy logs and `Referer` headers as a matter of course. A handle that does not resolve fails the request instead of sending it bare: a 401 from the far end is a much worse way to learn a secret was missing. The manifest gains `network.origins`, separate from the `network` permission, which only ever governed the guest's own sockets. "This plugin can talk to api.notion.com" is a sentence someone can judge at install; "this plugin has network access" is not. Redirects are not followed. Following one means re-checking the allowlist and the address rules and dropping credential headers across origins; until that exists, handing the 3xx back is the honest version. Responses are capped at 8 MiB and 30 seconds. Default ports normalize away on both sides, so a secret scoped to `https://api.notion.com` is still spent on `https://api.notion.com:443` — the handler and the egress path use the same rule so what is stored and what is compared cannot drift. Guest bindings regenerated with wit-bindgen 0.41.0, re-applying the two tweaks atomic-plugin/CONTRIBUTING.md documents. The existing example plugin still builds against them.
Until now the endpoint was reachable only by curl, which made the whole path unusable by the person it exists for. The secrets section sits on the plugin page, because a server-side WASM plugin is what can currently spend one — a browser-placed script plugin has no `fetch` and no manifest, so offering it a secret would be a lie. The value is write-only here as everywhere else: it is cleared from the form the moment it is stored, since there is nowhere to read it back from and leaving it on screen only risks it being seen. What is shown instead is what someone needs in order to revoke — the origins it goes to, and whether it has ever been used. The endpoint now answers with the manifest's declared origins alongside the secrets, in one request. Without them "store a secret" is a form with nowhere to send it, and a secret scoped to an origin the manifest does not declare could never be spent — so the UI offers exactly the origins that will work, rather than a free-text field that can be wrong. A plugin declaring no origins gets told to add `network.origins` to its manifest rather than an input that cannot succeed.
…step Step 4 said to build one as the first consumer. That conflated proving the fetch path works with proving the product goal, which is that a user asks and the assistant builds one. An importer hand-written by us would prove the opposite of what it appears to: we are careful where a model is sloppy, we read the source when stuck, and we work around rough edges that would stop the assistant dead. What the goal needs is the authoring loop, not an artifact with our name on it. The step is now what it should always have been — a throwaway plugin that makes one authenticated request and is then deleted.
… assumed The plan preferred one host-owned interpreter over componentizing each script, but that rested on two unverified assumptions. Both held. A Rust component using rquickjs builds for wasm32-wasip2 without a bespoke sysroot, and a component can take a script as a string while also importing host functions — so it does not have to be generated per plugin. Measured under the wasmtime 45 the server already runs: 1.1 MB shared by every plugin, 0.18 ms to instantiate fresh and run, and fuel stops an infinite loop rather than the host. Per-run instantiation is what stops a run inheriting anything from the last one, and at 0.18 ms it costs nothing next to a network round trip.
The counterpart to the browser Worker: same contract, same determinism, different placement. A run lands here when it needs the network, a secret, or when nobody is watching — none of which a browser placement can give it. The script is an argument, not an artifact. One 1.2 MB component runs every plugin, so promoting one from the browser to the server is a placement decision rather than a build, and there is a single binary to audit instead of one per plugin. Instantiating fresh per run costs 0.18ms, which buys the property that matters: nothing survives from the last run. Determinism is copied from `plugin-sandbox.ts` deliberately, not coincidentally. `Date` is frozen to `trigger.at` and `Math.random` seeded from the input, in both placements — a plugin is run on a fixture, on a sample, and then unattended, and if those disagreed none of those checks would mean anything. A test asserts the frozen clock and that two runs over one input are identical. Errors carry the JS message and stack across the boundary, because the author reading them back to fix its own code is usually an LLM and "Error" alone tells it nothing. That earned itself immediately: the first failing test reported `unexpected token: 'undefined' at parse` with a line number, which was `ctx.http` handing back a raw string where `read` and `query` return parsed objects. It parses now. Fuel stops a runaway plugin as a reported problem rather than a propagated error — the run failed, the server did not. A plugin has no stdio, no filesystem and no sockets: `typeof fetch` and `typeof process` are both `undefined`, and a test says so. `fetch` and `query` are not wired to the egress guard and secrets yet; they return a plain "not wired up" rather than pretending. That comes with the endpoint that uses this.
Five handler tests in Rust for the HTTP surface an app depends on: the view is served only against a scoped token, a token does not open another plugin, an app writes its own data signed as itself, it cannot write outside itself, and one without a key is told so. None of that needs a browser, and Playwright costs a hundred times what these do. Two e2e tests for the part nothing cheaper can reach — whether a null-origin iframe really loads the module the server served, renders it, and can talk back well enough to write. They caught two things. `query` used `store.search` with filters, and search drops filters whenever it falls back to the local index — property-value constraints need the server's. An app asking for its own children was quietly handed the whole drive. It read as correct on an almost-empty drive, which is how it survived being written and hand-tested; the second test in the same run saw 34 rows where it expected one. Now a collection, which is index-backed and actually filtered. And the starter listed everything under the app, which includes the app's own ontology and view. So it keeps its data in a folder of its own and says why — an app's parts being its children is exactly the thing that is easy to get wrong. Also adds `New app` to the menu. Apps could only be made by the assistant before, which left no way to test one and no way to make one without an LLM.
An app is a resource, so the Share dialog already works on one: the same rights arrays, the same invites, no mechanism of its own. Sharing the app shares its schema, its view, its handlers and its data in one act, because rights ascend the parent chain — and the view is a child, so read inherits and nothing has to be shared twice. Corrects the bar for /app-write from read to write. Read-only meant somebody shared a screen to look at could still add data through its buttons, which is not what sharing a resource means anywhere else here. Two checks now have to pass, and they answer different questions. May this person use the app to write at all — their rights. May the app write there — its rights. So someone who may write the whole drive still cannot make a buggy app escape its own subtree, and a read-only collaborator can open the app and see it without being able to change anything. Writes stay authored by the app whoever clicks, so two people using one app produce one voice rather than a history that depends on who was at the keyboard.
Records that a shared app works on one node with no new mechanism, and narrows the open question to the case that actually breaks: a collaborator syncing the drive to a node of their own, which has no key for the app.
Structurally these are the same thing — a table's rows are its children — so the difference is what a Table carries: a row class and display config. That difference is worth a lot. Rows an app adds are sortable, filterable, editable and exportable through the table, and someone who wants the data rather than the app can just open it. None of it costs the app a line. The starter was the argument for this. It drew a <ul>, which is a worse table, and it is the file every future app gets copied from. Exactly the mistake table-templates-and-mini-apps.md records the Timer making — made again, in the one place it would propagate. So createApp gives the app a row class in its own ontology and a Table for its rows, the client can ask for both, and the starter uses them. The table is named for what it holds rather than for the app: both sit under the app in the sidebar, and two entries with the same name is a question the reader answers every time.
Records why an app's rows are a table, that this is a nudge rather than a rule, and the distinction that decides the next step: an app can replace a table's view cheaply, but cannot embed one without the host rendering into it across the frame boundary.
A view kind is a plain string in the ontology, so an app's subject is a valid one — told apart from the built-ins by shape, the same tell /plugin-ui uses. Kept out of the ViewKind union deliberately: the built-ins are a closed set with labels and icons compiled in, apps are open-ended data, and folding them together would give every exhaustive match a case that cannot be written. Adding an app adds a tab. It never becomes the default, and the table's own views are untouched — which is the whole point, since the reason to put rows in a table was to keep them sortable and editable without the app. One way that promise could still have been broken, found by the test: a table with no saved views shows one implicit Table tab, and that tab disappears the moment a real view exists. So adding an app to a fresh table would have taken the table away. Now the Table view is created explicitly first. A node that has never heard of an app falls back to the table rather than an empty tab, because normalizeViewKind already narrows anything unknown — an app view degrades to the rows it is a view of. Inside the frame, getData() returns the table the app was pointed at and its own otherwise, so one app is its own thing on its own page and a way of looking at someone else's rows on a table tab, without knowing which it is.
Records the rule the feature exists to keep — adding a way to look at rows never takes one away — and the implicit-tab case that would have violated it on a fresh table.
Every app on the drive was offered as a view on every table. With a handful that is untidy; with fifty it is a menu nobody can read, and it offers a calendar app for a table of invoices — which will not work, and gives no hint of that until someone tries it. So an app declares the row classes it handles, and a table's view menu only lists apps that claim its rows. createApp declares the class it just made, so an app starts able to show its own rows and nothing else. Being offered on someone else's table means adding that class deliberately. An app that declares nothing is offered nowhere rather than everywhere. An app written against its own schema breaking on a stranger's rows is the failure worth defaulting away from, and declaring is a cheap price for the alternative. This is the same idea as class-url on the WASM extenders, which name the classes they apply to. Two mechanisms for one concept until that path is retired; the vocabulary at least now matches.
Records the rule and its default, and that this is class-url arriving in a second place — one concept, two mechanisms, until the artifact path retires.
An assistant asked for a CRM produced a good-looking app that stored every deal as JSON in one resource's description field. That is the obvious move for anyone used to localStorage, and it throws away what the platform is for: a blob cannot be sorted, filtered or edited in the table view, cannot be queried or shared per row, and two people editing at once overwrite each other wholesale rather than merging. Not the model's fault. The tool description predated the table work and never mentioned getData(), the row class, or add_table_columns — so there was nothing to tell it the rows were meant to be resources. It also went looking for a `children` property, which does not exist, so its load path silently found nothing every time and fell back to seeded demo contacts. So the description now leads with one resource per row, names the property that does not exist, points at add_table_columns for giving rows their fields before writing the view, and says not to invent demo data. create_app returns the table and row class so there is something to pass along. Also adds allow-modals to the frame. confirm() and alert() are the first things an app reaches for to guard a delete, and without it they returned false silently — the button did nothing and nothing said why. Still no allow-same-origin, so the frame stays null-origin. Also registers App in the New menu so it builds a whole app rather than showing a form for one empty resource, which asked for an entry point the user had no way to produce.
Creating an app failed schema validation on the server: entrypoint was required, and an app's entry point is one of its children, so it can only exist once the app has a subject. A property that cannot be set at creation time must not be required, or creating one is impossible. This survived every test because a local-first save resolves optimistically. The app appeared, rendered and worked; the commit died later in the outbox drain, was reported as a dropped stuck commit, and the resource simply never reached the server. Tests that assert what the screen shows cannot see that, so there is now one asserting the schema demands nothing at creation that creation cannot supply. Existing drives keep whatever shape the schema had the day they first used it, so a fix to the spec would never reach anyone who already ran the old one — the worst case, since theirs is the data that exists. ensureSchema now reconciles requires and recommends on classes and properties it finds. Names and descriptions are left alone: someone may have edited them, and overwriting a person's words on every boot is not a migration.
An iframe never grows to fit its document, so the height it is handed is the height the app gets and anything taller is simply clipped — no scrollbar, no sign that there is more. A fixed 60vh meant every app was cut off at the same arbitrary line with dead page beneath it. Both callers now size the box: the app page gives it the whole page, and the app-as-table-view tab is sized like the Kanban and Calendar tabs beside it. Not ContainerFull on the app page — its bottom padding exists so a scrolling column of resources clears the navigation bar, but a frame cannot grow into padding, so it only ever added the empty strip. The e2e now asserts the frame's bottom edge nearly meets the page's, because every other assertion in that spec is about what the app renders, and an app clipped in half renders its top half perfectly.
Building an app produced four sidebar rows, three of which read the same: the app, an ontology sharing its name, a table called "Items", and a loose agent at the drive root. Only the author knows what the rows are called, and `createApp` never asked — so no amount of prompting could have fixed it. - The row class and table take their names from the domain, singular and plural both, because English plurals are not derivable and a wrong guess is printed at the top of the user's table forever. "Feeding sessions", not "Items". The hand-made starter still falls back to generic names: nobody has said what it is for yet, so generic is honest there. - The ontology is "<app> schema". Two rows reading the same name under a third reading that name is a puzzle the reader solves every time they look. - An app carries an emoji, so a sidebar of them stays scannable. The app's agent moves into an "App identities" folder. It still cannot live under the app — an app may write its own subtree, so its agent resource kept there would be a public key the app could replace, a key stored in the room it unlocks. But one loose agent per app at the drive root scatters the answer to "what can write to this drive?" through the file listing, which is the question you need answered before revoking anything. The folder is found by a pointer on the drive, the way the drive already points at its default ontology, so renaming it does not silently start a second one. Also hoists a Date.now out of the tool definitions: it runs when the model invokes the tool, not during render, but the compiler's purity rule cannot see through the async callback and the error was failing lint on this branch.
Pure line-wrapping, no semantic change. These landed unformatted in earlier commits on this branch, which left `pnpm lint` red in three packages for reasons unrelated to whatever you were actually changing.
Reported from real use: editing a file while the assistant generated code lost the whole conversation, and it never appeared in the AI Chats panel. Nothing is broken. A sidebar chat is an in-memory draft until the first assistant reply completes, and the assistant's message is only persisted at onFinish. Each decision is defensible alone; together they leave the chat unsaved for exactly as long as the turn takes. Generating code is what makes you edit files, which is what triggers the reload — so the longest window is also the one you are most likely to interrupt. Records the diagnosis and three options rather than picking one, since the thorough fix writes on a hot path and should be costed before it is chosen.
An app could be created and never changed. The only repair for a bug was to create a second app and abandon the first — which strands the user's rows in an app they now have to delete by hand. `describeApp` reads one back, source included, because a model cannot fix code it cannot see, and a fresh conversation has no memory of writing it. `updateApp` replaces the source, the name, the emoji, or any combination. The source lives on the entry point, so a rewrite is not a replacement: the table, the row class, the schema, the agent and every grant survive it. Fixing a bug costs the user nothing they typed. Exposed as describe_app and update_app. create_app now points at update_app for later changes rather than at create_plugin with the entrypoint subject, which worked but nobody would guess.
An app's frame is null-origin by design, which also means its console belongs to nobody. A throw in a click handler left a dead button and no trace anywhere reachable — the platform behaved correctly and said nothing. Three routes out, all of them silent until now: - threw while opening — the message was painted into the frame, which reads to the user but is unreadable to everything outside it, including whoever could fix the app; - threw while being used — nothing at all; - a rejected promise nobody awaited, which is exactly the shape of the host refusing a write the app is not allowed to make. All three now post to the host. The stack goes with them: it names the line, which is the difference between "it broke" and a fix, and it describes only the app's own source, which its author may already read. The reporter is a classic inline script even though it appears after the module one — a module defers and a classic script does not, so it exists by the time a load-time throw looks for it. A test pins that, and fails if it is moved.
Reporting a failure is only half of it; the person reading the report did not write the app and would not know what to do with a stack trace. So the bar over a broken app carries one button, and it hands the whole thing to the assistant: the message, the stack, and the app as context, with an opening instruction to read the source and fix it. The bar sits above the app rather than replacing it. An app that threw in one button is usually still readable, and taking away what the user can see is the worse trade. `askAI` is general — a question put to the assistant from anywhere in the app, opening the panel on a new chat. Always a new one: auto-submit only fires on an empty chat, and a bug report does not belong in the middle of an unrelated conversation. The ask waits in the context provider because the sidebar may be unmounted when it is made, which is also why consuming it is an effect. Covered end to end, because the report crosses an iframe boundary no unit test can reach: break an app's source, reload, expect the message and the button.
A model that has just written an app has no way to look at it. It can read back what it saved, but not whether the code runs — and the failures that matter most are all invisible in source: a typo, a property that does not exist, a view that resolves having drawn nothing. So create_app and update_app now open the app off-screen, in its own sandbox with its own agent, and wait. The result comes back in the same tool call, and the model fixes it before the user ever sees it. The frame had to learn to report success, not just failure. Silence is not success: an app still loading and an app that rendered nothing look identical from out here, so it sends the child count when view() resolves. Zero children is its own verdict — the user opens that to an empty panel, which reads as "nothing happened" exactly like a crash does. Three verdicts, and the wording is the feature. A model handed a neutral status field will report "your app is ready" beside it, so anything short of working reads as an instruction to keep going. A timeout says "could not tell" rather than "failed" — calling a slow app broken sends a model rewriting code that works, and the rewrite could be worse. The run is real, which means an app that writes on load writes here too. That is accepted rather than blocked: the app's rights already confine it to its own subtree, opening it does the same a moment later, and a read-only run would report a false failure for every write it refused. The verifier's messages and the Fix-it prompt are `@wc-ignore`d — they are read by a model, and a translated tool name is not a tool name.
The extractor picked up appCheckReport's verdict text, which is written for a model and names update_app. A translated tool name is not a tool name, and a French user's broken app would have sent the assistant after a function that does not exist. Found by running the e2e suite: the same class of mistake as the Fix-it prompt, in a file created after that one was fixed.
Asked for a sales pipeline, the assistant wrote a kanban board and a table by hand: several hundred lines reproducing seven parameters that already exist, and getting the numbers wrong on top. Every card carried a "Move: Lead" dropdown where the real board has drag-and-drop. It did that because this description told it to. It offered "a tracker, a dashboard, a little tool" as the reason to reach for an app — and a tracker is a table with views, and a dashboard is a Dashboard. The description now leads with the routing rule and lists the tells: cards in columns, a grid, a search box, a sort control, a total, an add button. Each one names the configure_view parameter it should have been. Configuring cannot be fumbled the way six hundred lines can, and it arrives with drag-and-drop, live sync, undo, keyboard navigation and export already working — none of which a hand-written view starts with or catches up to. An app is still the answer when the interaction itself is the point: a timer with two thumb-sized buttons, a seating chart, a map, a game. The test is in the description, because "last resort" without a rule is just a tone, and the model will find its own case exceptional every time: if the same rows in a plain table would lose the user nothing, it is not an app. Moved out of the tools file so it can be tested without mounting the app, and because it is the sentence that decides which product the user gets. The test pins the routing, including that the old wording cannot come back.
The CRM the assistant wrote by hand already existed as a template — and the template is better. It brings Company, Stage, Value, Last contact, Owner and Notes; a kanban pipeline grouped by stage; deal value summed and broken down per stage; and a computed "days since contact" column, sorted oldest first because the point of the view is who to chase. The hand-written version had none of that, replaced drag-and-drop with a per-card dropdown, and showed $0 everywhere. So the ladder is three rungs, not two, and you take the first that fits: a ready-made template, then a table with views, then a custom app. The previous wording sent row-shaped work to rung 2, which is right about apps and still skips the rung that needed no work at all. Stated in all three places rather than only in create_app, since a model that starts at create_table never learns the templates exist: list_table_templates now says it is the first thing to try, and create_table says to check it first. The ordering test anchors on the rung labels. Anchoring it on tool names looked fine and proved nothing — `create_table` is a substring of `create_table_from_template`, so the search found rung 1 and rung 2 was never checked at all.
Line-wrapping only. These lines predate the branch's own formatter run and were never reflowed; the rebase brought them past a check that now sees them.
Gemini returns each reasoning block with a signature and rejects the entire request if one comes back without it — `400 Corrupted thought signature`. The turn dies, and reopening the chat does not help, because the thing it objects to is in the history. We do not always still have the signature. `ai-reasoning-part` stores the words and never stored the provider metadata, so a message rebuilt from its resources — a reopened chat, a reload, a restore after compaction — yields reasoning that looks intact and cannot be sent. From then on the conversation is unusable. Reasoning without its metadata is now dropped before the request. The test is possession, not provider: stripping reasoning wholesale would be simpler and wrong, because Anthropic requires its thinking blocks preserved across a tool call, so a blanket strip trades one provider's error for another's. A model does not need its own previous thinking replayed; it needs the conversation. An assistant turn left with nothing but unsendable reasoning is dropped too, since an empty turn is its own kind of malformed.
The rebase resolved every catalog conflict by taking this branch's side, which dropped the strings develop added — the private-drive badge, its share warning, the error handler's fallback. They would have rendered as [i18n-404] here. Settled by running the app, which is what matches the dev server; see the note in AGENTS.md about why `pnpm clean-translations` lands somewhere else.
joepio
force-pushed
the
feat/plugin-model
branch
from
August 28, 2026 08:12
a2d44ef to
d18cb41
Compare
Settles vocabulary and shape for work that spans three existing docs. Decided in conversation, so written down before it has to be decided again. Users see two words: templates and integrations. "App" is not a category — thirteen of fourteen candidate mini-apps shipped as pure configuration, so an app is how a few templates are built, not something anyone browses for. Offering both as siblings is the mistake already made in the assistant's tools, where two curated sources with no rule for choosing produced a hand-written CRM instead of the CRM template. Automations get a third word when the first one ships, not before. The catalogue is browsed by shape under New and by brand under Import, because nobody thinks "I want a table template" — they think "I use Notion". A tested catalogue starts at about three entries, which reads as supporting nothing, so the search that finds nothing offers to have one written. That is what makes a small catalogue honest rather than embarrassing. For automations: no node editor, because the verdict preview already answers the question a node editor exists to answer, and better. The trigger stays declarative and already is. Outputs split into proposed and immediate, and there is a hole — ctx.http fires during the run that produces the verdict, so an outbound call escapes the review it appears to be under. Also records that #1243 gives the notification contract and not its producer: zero server files means an automation's "notify me" needs a second, server-side writer of the same resource.
Both the badge tooltip and the share warning listed notifications among what the private drive holds. It does not: notifications are #1243, still unmerged. What is actually there is the saved-drives list (seeded in `ensurePersonalDrive`), favourites, and AI chats. Warning someone about data that does not exist teaches them to discount the warning, which is the one thing this copy cannot afford — it is the last thing between a private drive and being handed to a colleague.
The package's CLAUDE.md tells contributors to run `pnpm typecheck` before they are done, and it has been failing on everything: `tsconfig.build.json` never set `types: ["node"]`, so sixteen errors were `Cannot find name 'node:fs'` from test files and nothing else could be seen behind them. Behind them were four real ones: - `upgradeDocument` tests `raw instanceof Uint8Array` on a value the ontology types as `never`, because `documentContent` has no datatype. The runtime check is right — legacy documents did store bytes there, which is why the migration exists — so widen at the comparison and say why. - an `enrollment` test cast inside `.catch`, which types the awaited result as the union rather than the error. No behaviour changes; the script now exits zero, so the next person who follows the instructions gets a signal instead of noise.
joepio
force-pushed
the
feat/plugin-model
branch
from
August 28, 2026 10:25
ca30438 to
9a60e13
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Implements the substrate for #1304 — "Apps inside Atomic: vibecode entire UIs + schemas + logic".
An app's code is a property on a resource in the drive, not an artifact on the server's filesystem. That single choice is what makes the four problems in #1304 fall out:
renders, and shows up as a tab beside the Table view rather than replacing it.read/writeare the scopes, revocation is removing the DID. No parallel permission model — seeplanning/issued-agents.md.What's in it
createAppbuilds all of it.@tomic/libwith no bundler, no npm and no build step.App identitiesfolder — never under the app, since an app may write its own subtree and would otherwise be able to replace its own public key.create_app/update_appopen it off-screen and report whether it rendered.The routing rule matters as much as the substrate
Asked for a sales pipeline, the assistant first wrote a kanban board by hand — several hundred lines reproducing seven
configure_viewparameters, with aMove: Leaddropdown where the real board has drag-and-drop. The tool description had told it to.The ladder is now explicit, and you take the first rung that fits: a ready-made template, then a table with views, then a custom app. Pinned by tests, including that the old wording cannot come back.
Review
It is large — 111 commits, 153 files. Suggested reading order if you want the argument rather than the diff:
planning/— each feature landed with a note on what it does and does not cover.browser/lib/src/plugin-app.ts— what an app is made of.server/src/plugins/store_host.rs— who writes, and as whom.browser/data-browser/src/chunks/AppPage/— the host side.Not done
create_appalways builds the app its own table; it cannot yet attach to rows the user already has (only the Add view menu can).validateas a third export — the last thing keeping the WASM/zip path alive.Testing
Unit tests across
@tomic/lib, data-browser and the server;apps.spec.ts(5) andplugins.spec.ts(4) run green locally against a real server and browser. Lint and fmt clean in all touched packages. CI has not run this branch yet.