Skip to content
Concepts

Concepts

Immutable App/Release catalog with verified public website delivery

Puffball is a catalog for web apps and their immutable releases. It records which App exists, which Releases it has, and which Release each public route serves. Before anything is served or selected, it reads the artifact bytes and checks them against the digest the Release recorded.

Use it when you host apps for other people and need three guarantees: a Release never changes after you create it, a route only serves a Release you explicitly promoted, and a revoked Release stops serving right away. Team installation, permissions, billing and execution stay with your host.

Methods may declare hidden: true to omit maintenance operations from Workflow and Agent/MCP discovery. The marker is preserved in the digest-covered App API; it grants or denies no direct-call authority. Unmarked methods remain visible.

An API can opt into a dynamic tool namespace with toolFamily: { name: "flows", list: "listWorkflowTools", call: "runWorkflow" }. The descriptor is signed with the Release. Its listing must be a declared read method, its call must be discoverable and non-external, and its namespace must not overlap static methods. Omitting it preserves ordinary API bytes. Inventory and schema metadata grant no invocation authority.

An App API may include events: Record<string, { description, payload }> beside methods. payload is a bounded JSON Schema projection for the whole emitted payload. Agar generates it from the backend’s Zod event schema. Puffball’s appApiSchema preserves it through the App declaration and immutable Release codec, so changes to event metadata change the Release bytes and digest. An API without events omits the field. Event declarations grant no authority; the host owns installed-Release selection, emission admission and routing.

Emitting Apps request the app.events.emit capability. The maintained EVENTS.emit operation requires that capability, approved explicitly per installation through the host’s existing capability grant. Declaring an event or binding does not approve emission.

The catalog accepts at most 64 events, names of 1–128 characters, descriptions of 1–1000 characters, and the same 20,000-byte/depth-16 projection and 256,000-byte total API bounds as method metadata. These are declaration-read bounds, not event-delivery or retention limits.

The runtime-neutral ./app-method-runtime entry owns HostActor, WorkflowAttestation, AppMethodCallContext and their maintained parsers. Actors are members, Agents or Team keys identified by their IDs. The host may attach a role (owner or member) and a Workflow attestation containing workflowId, runId, nodeId and the typed armedBy actor. Ordinary calls omit the Workflow field. The Effect caller decoder delegates to this same grammar.

These facts come from host admission, separately from App method input. Decoding validates their representation; it does not grant authority or verify that a run is active. Invocation and Git owners re-admit the stored actor and run before effects.

Use the public testing entry with a caller-owned Node SQLite database:

TS
import { DatabaseSync } from "node:sqlite";
import { APP_RELEASE_D1_FRESH_SCHEMA } from "@fungi.computer/puffball/cloudflare";
import { sqliteD1 } from "@fungi.computer/puffball/testing";
const sqlite = new DatabaseSync(":memory:");
sqlite.exec("PRAGMA foreign_keys = ON");
sqlite.exec(APP_RELEASE_D1_FRESH_SCHEMA);
const database = sqliteD1(sqlite);
const apps = await database
.prepare("SELECT app_id FROM app_release_apps")
.all();
console.log(apps.results); // []
sqlite.close();

The adapter uses the public Watchdog Node SQLite owner. It supplies prepared statements and atomic batches for tests; it is not a Cloudflare emulator or isolation boundary. The caller owns schema setup and database lifetime.

Record Owns
App Stable global identity registered in the catalog.
Release Immutable App artifact reference, digest and source/build provenance.
Public Surface An App-owned stable route with an explicit selected Release.
Transition receipt The recorded result of an operation-scoped selection or revocation.

IDs describe identity. They do not grant permission to change that identity. Each ID has a fixed grammar, such as app_<a-z0-9>, rel_<a-z0-9>, ps_<a-z0-9>, artifact:<name>, idem_<key>, git:<40 hex>, sha256:<64 hex> and a single-segment route like /hello. Parse untrusted strings with the root parse* functions. A rejected value throws AppReleaseInputError naming the parser.

The root entrypoint exports createAppReleaseCatalog, the parse* boundary parsers, the record types, the error classes and the App declaration vocabulary. The catalog is a Promise facade over an Effect core. The ./effect entrypoint exposes that core (createAppReleaseCore), the decode* functions and the in-memory store. Both use the same grammar and decisions.

A Release artifact is an App root with media type application/vnd.fungi.app+json and format: "fungi-app-v1". It binds a visual part (an HTML page or an asset manifest), an optional durable backend artifact, or both, under one digest-covered description. An App can be visual, headless or both, but never empty. ./app builds and parses App roots, ./backend parses backend descriptors, and ./assets parses asset manifests.

Declared capabilities, handlers, roles and commands describe what an App may ask its host to expose. They do not approve those operations.

An App can include bounded keys for its own data in fungi.app.storage:

JSON
[{ "key": "desktop", "scopes": ["team", "member"], "maxBytes": 16384 }]

Each digest-covered declaration allows at most 16 distinct keys. A key matches [a-z][a-z0-9.-]{0,63}; its non-empty scopes are a unique subset of team and member, and maxBytes is from 1 through 65,536. This requests storage for the App’s own installation data. It grants no access to another App, installation or Team; the Hub owns scoped admission and write authority.

The artifact reader resolves a stored opaque reference and verifies it. Verification checks the expected digest, content type, configured size bounds and an owned copy of the bytes. A reader never uses a caller-supplied digest to find different bytes. Asset manifests also bind object versions, digests, lengths and a mount base. Your host keeps admission and document isolation.

Create the App before its Releases and Surfaces. A Release or Surface for a missing App throws AppReleaseOwnerAppAbsentError or AppReleaseSurfaceOwnerAppAbsentError. Replaying the same immutable record returns it. Reusing an identity with changed material fails, and for a Release that is AppReleaseImmutableConflictError.

Creating a Release does not promote it. Verify its artifact, then request an explicit promotion or rollback to an eligible Release that belongs to the App. Unverified, revoked and cross-App selections fail. Retrying a transition with the same idempotency key and input returns its receipt. Changed input under that key throws AppReleaseTransitionIdempotencyConflictError and leaves the selection alone.

A revocation stops eligible delivery and selection. It does not edit immutable artifact bytes or decide how a Team restores an installation. Your host’s backend state lifecycle and schema migrations are separate from Release facts.

resolvePublicWebsite(route) takes a canonical stable route, reads the current selection, verifies the artifact and rechecks authority before returning bytes. A private cache never skips that recheck. Request bodies, cookies and caller-supplied Release IDs cannot select unpublished content.

./cloudflare provides createD1AppReleaseStore for catalog state, createR2ArtifactReader for artifact reads, and APP_RELEASE_D1_FRESH_SCHEMA for an empty database. ./cloudflare-assets reads and verifies asset builds from R2. ./publisher holds the schemas for registering an App’s source repository with a publishing Team. The adapters do not provision resources or supply Team authorization.