Skip to content

feat(apps): vibecode an app inside a drive — UI, schema and logic as data - #1307

Open
joepio wants to merge 115 commits into
developfrom
feat/plugin-model
Open

feat(apps): vibecode an app inside a drive — UI, schema and logic as data#1307
joepio wants to merge 115 commits into
developfrom
feat/plugin-model

Conversation

@joepio

@joepio joepio commented Aug 25, 2026

Copy link
Copy Markdown
Member

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:

#1304 says LLM-vibecoded software lacks… How this addresses it
Persistence Rows are ordinary resources in an ordinary Table. Sortable, filterable and editable without the app.
Sharing An app is a parent whose children are its parts, and rights ascend the parent chain — sharing the app shares its schema, its view and its data in one act. No new mechanism.
Using existing data An app declares which row classes it renders, and shows up as a tab beside the Table view rather than replacing it.
Auth An app gets its own Agent. A DID is the principal, read/write are the scopes, revocation is removing the DID. No parallel permission model — see planning/issued-agents.md.

What's in it

  • App resource with its own ontology, row class, Table and entry point; createApp builds all of it.
  • Source served from the drive into a null-origin iframe, with a short-lived view token — the frame can't sign for its own source, so the authenticated page mints it a capability.
  • A Store over postMessage, so an app uses the same API as @tomic/lib with no bundler, no npm and no build step.
  • App agents, kept in an App identities folder — never under the app, since an app may write its own subtree and would otherwise be able to replace its own public key.
  • Unattended runs sign as the app, so cron and query-triggered writes are attributable.
  • Secrets encrypted at rest under a node key; the wrapper set is derived from the trigger rather than configured.
  • A Rust planner + applier mirroring the TS pair, pinned by a shared fixture corpus so the two cannot drift.
  • Errors have somewhere to go: a broken app reports out of the frame, and one button hands the message, stack and app to the assistant to fix.
  • An app is run once before anyone is told it workscreate_app/update_app open 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_view parameters, with a Move: Lead dropdown 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:

  1. planning/ — each feature landed with a note on what it does and does not cover.
  2. browser/lib/src/plugin-app.ts — what an app is made of.
  3. server/src/plugins/store_host.rs — who writes, and as whom.
  4. browser/data-browser/src/chunks/AppPage/ — the host side.

Not done

  • create_app always builds the app its own table; it cannot yet attach to rows the user already has (only the Add view menu can).
  • Reads are session-scoped, not app-scoped.
  • A collaborator syncing a shared app to their own node has no key for it.
  • validate as 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) and plugins.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.

joepio added 30 commits August 26, 2026 15:25
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.
joepio added 25 commits August 26, 2026 15:27
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
joepio force-pushed the feat/plugin-model branch from a2d44ef to d18cb41 Compare August 28, 2026 08:12
joepio added 3 commits August 28, 2026 10:13
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
joepio force-pushed the feat/plugin-model branch from ca30438 to 9a60e13 Compare August 28, 2026 10:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant