Sandbox persistence
Sandbox obtains its asynchronous PluginDb with await ctx.db(). The host awaits the entry point's canonical migrate(db) export before calling register(ctx) during daemon loading. The export delegates to PluginDb's transactional migration runner without a runtime context; a failed migration prevents registration. Registration consumes the migrated handle directly before registering controls or starting reconciliation. Runners consume the daemon-migrated schema. Direct fixtures explicitly await the same export before creating stores. The fresh PostgreSQL migration lives in lib/schema.mjs at version 21. Existing installations already record it and skip this baseline; fresh installations run it, and later migrations start above 21. Every object belongs to the quoted schema supplied by the host, normally plugin_sandbox.
Transaction binding
createEnvironmentStore(db, processIdentity) creates the runtime's store. Its transaction(async (store, db) => ...) callback receives a new store and the PluginDbTx bound to the same connection. Every database read and write in the callback uses those supplied values. Root methods return promises. Access descriptors, account identity, allowed roots, shell environment and GitHub session credentials are awaited at their host boundaries. Credential-owner failures log one fixed diagnostic without owner error bytes; launch tokens remain confined to the launch environment and output-redaction closures. Transaction-bound runtime and operation reads take row locks before lifecycle admission and ownership claims; read-only callbacks use repeatable-read snapshots without write locks.
Core owns Project deletion admission and finalization; Sandbox never calls beginDeletion or finishDeletion. Transactional account, administrator and membership rechecks for ordinary environment actions use the explicitly supplied db.host facade. Environment metadata creation, lifecycle enqueue (including snapshots and automatic recovery), execution lease admission and managed-worktree create/remove intent call db.host.projects.requireActive(id) inside their write transaction, before the plugin row lock or mutation. Core holds the active Project row with FOR SHARE until commit, so deletion admission cannot overtake these writes. Worktree guest execution remains outside SQL and takes the same fenced execution lease; cleanup under core deletion does not require an active Project. Immutable turn access is resolved before entering SQL; root host stores and root PluginDb handles cannot escape the transaction. Refused-start alerts run after the admission transaction rejects, so their Project label lookup and alert persistence also stay outside its context.
saveOperation and log start a short transaction when called alone, or join their caller's bound store. Resource-scoped advisory transaction locks serialize history trimming and log-ring writes. Host provisioning and network-port admission use transaction advisory locks because no stable runtime row covers those shared claims. No filesystem or guest operation belongs inside those transactions. Progress events are published after the durable write completes.
Resource sampling prepares validated values before opening a transaction. The transaction first compares the runtime generation, refresh epoch and sample-start timestamp. Only a successful snapshot CAS calls createResourceHistoryStore(db).append(samples) on that same transaction handle. A failed callback rolls back both the snapshot and its minute/hour history.
Core Project removal storage phase
The manifest declares provides.projectRemoval: "storage". Registration uses
ctx.registerProjectRemoved(id => environments.removeProject(id), { describe: id => environments.describeProjectRemoval(id) }). Core calls the acknowledged
callback in the daemon, outside a turn, after service participants such as Sites.
The callback is internal to the runtime, not exposed on the Sandbox control.
Admission and recovered delete operations require the core Project's
lifecycle: "deleting" and projects.deletion(id) in state pending, with
plugin:sandbox in participants and every preceding step present in
completedSteps. Old pending environment deletions cannot bypass missing core
intent, unconfirmed migration work or unfinished service cleanup. Ordinary
environment requests reject delete; queued ordinary actions cannot execute
after core closes the Project. A remaining publication fails closed rather than
being silently discarded.
The description reads a repeatable-read snapshot of existing runtime, retained
snapshot and historical machine recipes. It returns canonical storage roots,
disk directories, rootfs and component paths, plus snapshot directories and
machine/snapshot labels. It performs no machine I/O and invents no path for an
unprovisioned environment. Busy or unreadable metadata rejects the preview; core
reports the owner unavailable and must not admit deletion. Before any destructive
step, the executor checks that every described path belongs to the core receipt's
confirmed paths. Changed paths require renewed confirmation, not implicit
permission. Adopted host directories belong to core's file phase.
Removal waits for an in-process operation to settle, supersedes unclaimed work
while retaining its machine recipes, and enqueues or continues the existing
durable environment delete operation. A live foreign claimant blocks removal.
The system receipt uses request_key: "core-project-removal",
initiator: "system" and no account owner. Progress still travels through the
environment operation event and authorized operation read; core owns the
user-facing Project deletion outcome.
Cleanup cancels leases, stops desktops and machines, removes owned generations, snapshots and disks through the existing verified, idempotent runtime primitives, then atomically clears environment metadata, uploads, worktrees and runtime logs and queues bounded resource-history deletion. The successful operation receipt survives; no completion log recreates deleted Project logs. Failure propagates to core, with the checkpoint retained for retry or restart. Core finalizes its Project and dependent rows only after the callback has verified success. Without the registered storage owner, core must keep managed Project deletion blocked.
Environment HTTP operations
Sandbox declares and registers GET and POST projects/:id/environment through
ctx.registerApiRoute, served at /plugins/sandbox/api/projects/:id/environment.
The host matches the complete pattern and decodes req.params.id exactly once;
req.path is empty. Missing segments, malformed escapes and undeclared suffixes
return 404 before the handler runs. Sandbox accepts only a canonical positive,
safe integer Project id and returns 404 for other parameter values.
GET reads the overview without provisioning. POST validates only action,
requestId and expectedGeneration, derives the initiator from verified
credentials and returns the durable environment operation. Both require a linked
account and enforce accessibleProjects before the runtime rechecks live
membership. A delete action returns project_deletion_required; callers must use
core Project deletion instead. A missing registration returns 404. Matching adds no I/O; lifecycle
writes still use the transaction and operation dispatch paths described above.
Ordering and bounded maintenance
runtime_operations.row_id is a bigint identity. Recovery reads oldest identity first; dock and recent-history queries read newest first. Separate indexes cover account/identity and resource/identity walks. Settling an operation removes at most 128 older eligible receipts and retains the newest 20 rows. Receipts containing old or new machine recipes remain available for cleanup.
Runtime logs use their own identity and keep 200 rows per resource. Minute samples and hourly rollups retain natural uniqueness on Project, metric and bucket while also exposing unique identities for bounded maintenance. A read-only existence check skips the write transaction when neither expired rows nor durable Project-deletion work exists. When work exists, one prune transaction removes at most 1000 sample/rollup rows across expiry and durable Project-deletion work. Ordered identity selection uses FOR UPDATE SKIP LOCKED; another transaction holding a sample does not stall the maintenance pass.
Leases
Execution/repository lease insertion, heartbeat repair and identity-conditional release use standalone statements. They never acquire a global writer lock or run behind resource history transactions. Repository claims use uniqueness plus ON CONFLICT DO NOTHING. Stale cleanup deletes only the observed process identity, preserving a successor that appeared after the inventory read.
Managed execution cancellation writes the monotonic requested marker asynchronously. The same handle waits for its pending intent before reading cancellation state or finalizing. Persistence failures are reported and propagate to cleanup; an unresolved guest keeps its durable lease.
Desktop lifetime
The desktop producer uses the PluginDb handle passed by environment boot; it opens no additional database or pool. Disposal first fences admitted operations and channels, then drains the existing per-Project operation queues, including a cold desktop preparation that has not admitted a live display yet. Descriptions invoked after disposal return null before any SQL; a description already awaiting its first read checks disposal again when that read completes, so retained callbacks do not start work against a closed plugin pool. The provider's asynchronous description must also be awaited by core desktop callers. After its asynchronous checks, a description verifies that the same live observation is still admitted; a concurrent stop cannot return the fenced display as ready.
Verification
The suites are tests/plugins/sandbox*.test.ts and tests/plugins/environment*.test.ts under the repository's root vitest.config.ts, whose global setup starts an ephemeral socket-only PostgreSQL cluster and gives every fixture its own clone. Run them only inside the repository's bwrap boundary, through heavy-gate, with at most two Vitest workers. Guest payload bytes are mocked by plugins/sandbox/tests/setup.ts because persistence suites drive fake machines rather than a built desktop.
The tests use the assembled elowen-db package directly. Fixture host bindings implement asynchronous prepare() and return the lazy synchronous binder; SQL-only fixtures prepare successfully and refuse only an actual tx.host lookup. Full plugin/API fixtures await createCoreDatabase on a fresh harness clone with the fixture's daemon pool size of four, and close it before the harness drops the clone. Tests cover unrelated long transactions, bounded identity retention, atomic resource rollback, operation claims and retained state across reopening.