feat(apps): vibecode an app inside a drive — UI, schema and logic as data - #1307
Open
joepio wants to merge 98 commits into
Open
feat(apps): vibecode an app inside a drive — UI, schema and logic as data#1307joepio wants to merge 98 commits into
joepio wants to merge 98 commits into
Conversation
joepio
force-pushed
the
feat/plugin-model
branch
2 times, most recently
from
August 28, 2026 10:25
ca30438 to
9a60e13
Compare
joepio
added a commit
that referenced
this pull request
Sep 1, 2026
…tus lines (#1320) Deleted (superseded): llm-wasm-gui-plugins.md, importers.md, habits-app.md (folded into plugins.md), on-device-atomic-daemon.md (Android half lives in android-data-reuse.md, desktop remainder is a note in virtual-drive.md), rust-dependency-upgrade-audit.md (done). plugins.md is imported from feat/plugin-model (PR #1307) and annotated: everything it calls built lives on that branch, not develop. Corrected status lines: json-schema-code-first (PR #1262 in flight), virtual-drive (shipped as desktop NFS mount), table-view-filters (multi-view switcher shipped), actions (step 1 shipped), unified-sync (Phase 0b / OQ4 / OQ6 drift), encryption (opfs + vault v1 shipped), cloud-sync-managed-node (verified against LocalProcessNodeProvider only), sync.md (Flutter WS session shipped), structural-problems-index #1 (still open, compiler on). Dated "Current (...)" lines reconcile: same-agent vs rights-based peer trust (683a25d, 2026-07-17); DID = sig(genesis cert) not sig(genesis commit) (0b1b13b / 232aca8); cross-user QR pairing; legacy strokeData string parsing split by layer; multi-constraint queries vs single-pair WS subscriptions. README index rows updated to match; silent-failures.md indexed. Claude-Session: https://claude.ai/code/session_019asLKBrBWY5ovyeCgtmdSd
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.
An app may write its own subtree. So a grant kept there would be a permission the app could widen for itself, and the first thing a hostile app would do is grant itself the rest of the drive. The rule is enforced when reading grants rather than left to convention, and there is a test for exactly that escalation. A grant names the app, what it may write beyond itself, and who allowed it — so it can be answered for later. A granted subject covers what is under it, and nothing beside it. The deciding is split from the fetching: allowanceFrom() takes grants as read and returns what they permit, so the rules can be read and tested without a store in the way. The first version tested this through a fake store and spent its effort imitating findSchema instead of checking the rules.
Replaces the plugin-grant class added yesterday. That was the parallel permission model planning/issued-agents.md (PR #1275) explicitly says not to build, and its worst property was that it was enforced only where the browser host happened to run — a scheduled or triggered run never consulted it, so an app's permissions meant one thing in a tab and nothing at 3am. An Agent already is a token: the DID is the principal, read and write on resources are the scopes, revocation is taking the DID off those lists. So createApp issues the app one and grants it write on the app itself. Rights inherit down the parent tree, which means 'an app may write its own data' is now something the ordinary rights walk says rather than a rule this codebase restates. The agent resource is parented to the drive, not the app: an app may write its own subtree, so its agent kept there would be a public key the app could replace. issueAccessAgent and its tests are taken from #1275 unchanged. The subtree check in hostStore stays, demoted from authority to early answer — the same verdict the rights walk will reach, arrived at in time to hand the app an error it can show. The secret is returned once and stored nowhere here. Signing as the app is the next step; until then writes still go out under the session's agent, so the ACL is provenance and a plan, not yet a bound.
config.toml holds this server's agent secret and was created 0644 — readable by every account on the machine. Narrowed on every boot, so an existing install is fixed by upgrading rather than by reading a release note. Secrets in the store are now wrapped with a node key kept beside the config at 0600, using the envelope that already existed for vault secrets rather than a second scheme. A wrapper kind was added to it; the format is otherwise untouched. What this buys is every way a database leaves a machine intact — a stolen disk, a backup, a copied store file, a support bundle — leaving with ciphertext. It does not protect against a compromised running server, and cannot: a plugin importing at 3am has nobody to ask for a passkey, so the process must be able to open what it opens. Saying otherwise would be theatre. Wrapping lives in Db rather than at each call site because there were three readers and one writer, and a secret written in the clear because one caller did not know is indistinguishable on disk from one nobody meant to protect. A secret stored before there was a key still opens, or upgrading would lock people out of their own credentials. Follows: arming a schedule or a trigger now refuses when a plugin holds a secret this node cannot open alone. That case cannot arise yet — a user-wrapped secret is the next step — but the check belongs with the arming, where someone is present to read it, rather than being retrofitted after the first silent 3am failure.
Says plainly what a node key buys (a store that leaves the machine is ciphertext) and what it cannot (a compromised running server), because an unattended run means the process must be able to decrypt unaided.
Until now every scheduled or triggered commit was signed by the server's own agent while rights were checked against the granting one — signature and authority came from two different identities, so the history said the server decided something an app decided. An app's key now lives on the node, wrapped by the node key like every other secret. It has to live there rather than with the person: an app importing at 3am has nobody to ask for a credential, so whatever signs its writes must be openable unattended. Its own tree, apart from plugin secrets, and that separation is the point. A plugin secret is spent by substituting secret:<name> into a header; if an app's signing key sat in the same store under a name, a plugin could write an Authorization header naming that key and post its own identity to any origin it was allowed to reach. Two accessors, neither of which returns the key: one says which DID an app writes as, the other hands the agent to a closure that signs. Found by the test: creates were still signing as the server after set and remove had been fixed. That path matters more than the others — under a DID drive the signature *is* the subject, so signing as the server does not mislabel the author, it mints the app's own data under the server's name. create_app now hands the key over at creation, and reports when it could not: the app still works while you are present, it just cannot act on its own.
A commit is signed by whoever's key is in the page, and in a browser that is the user's. So a write from an app's UI was authored by the person and bounded by what the person may reach — which left this codebase's own JavaScript as the only thing between third-party code and the whole drive. That is the host being polite, not the server refusing. The server performs the write instead, signed by the app's agent and checked against the app's rights. The bound stops being advisory, the author is the app, and a click in a tab lands the same way a scheduled run does. The frame is never given the key: a secret in a null-origin iframe is extractable and never expires. Opening an app is enough to drive it — read rights on the app. What it may then write is the app's business, not the opener's: a person who may write the whole drive does not lend that reach to an app just by looking at it. Reads deliberately stay on the session's store. An app sees what the person looking at it can see; a write persists and is attributable, a read is already on their screen. Routing reads too would also cost a round trip and the cache for every property an app renders. The subtree check stays in the page, demoted to an early answer: the same verdict the rights walk reaches, in time to hand the app an error it can render instead of a refused round trip.
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.
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.
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.
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.
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.
The dev server is a second writer on tracked files: wuchale extracts as vite serves. That cost real time four separate ways in one session, and none of it was written anywhere. The rebase one is the worst to debug — the extractor rewrote the .po files between steps, so git insisted there were merge conflicts to resolve while `git diff` showed nothing unmerged. Nothing in the message points at vite. Also recorded: two writers corrupt the catalogs; committed catalogs that are not the extractor's fixed point make every dev-server start rewrite them, which reloads the page and abandons in-flight work (that silently cancelled a destroy mid-test); `pnpm clean-translations` extracts from test files and so settles on a different fixed point than the dev server; and model-facing strings have to be @wc-ignore'd, because a translated tool name is not a tool name.
joepio
force-pushed
the
feat/plugin-model
branch
from
September 4, 2026 12:20
a74c246 to
c432352
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.