Skip to content

refactor!: replace @deroll/app with chain composition over Rollup.run - #205

Merged
tuler merged 6 commits into
prerelease/v2from
claude/chain-prototype
Aug 13, 2026
Merged

refactor!: replace @deroll/app with chain composition over Rollup.run#205
tuler merged 6 commits into
prerelease/v2from
claude/chain-prototype

Conversation

@tuler

@tuler tuler commented Aug 13, 2026

Copy link
Copy Markdown
Owner

Alternative to #203 — open both and pick one. This branch contains #203's commits plus three more, so the two PRs are the same base and the same starting point, differing only in how far they go. Merging this closes #203.

The question

#203 kept @deroll/app on the argument that multi-handler composition is real value the binding doesn't provide. That's true, but it does not follow that composition needs a class with a loop and five pass-through methods.

Reading the binding's run() implementation settles it. Deminified:

async run(handlers = {}) {
    let accept = true;
    for (;;) {
        const request = this.finish({ accept });          // ← outside the try
        try {
            accept = request.type === "advance"
                ? (handlers.advance ? await handlers.advance(request, this) !== false : false)
                : (handlers.inspect ? await handlers.inspect(request, this) !== false : false);
        } catch (e) {
            accept = false;
            this.emitReport(Buffer.from(String(e?.stack ?? e)));
        }
    }
}

That catch block is character-for-character the error policy #203 implements in NativeApp — reject, emit the stack as a report, same String(e?.stack ?? e) formatting. And handlers.advance ? … : false is the same reject-by-default. #203 didn't just align deroll's types with the binding; it converged deroll's loop semantics onto run()'s. What remained in NativeApp was duplicated logic.

So: delete the wrapper, keep the composition.

The API

const rollup = new Rollup();
const wallet = createWallet();
const router = createRouter();          // ← no { app }

rollup
    .run({
        advance: chain(wallet.handler, application),
        inspect: router.handler,
    })
    .catch((e) => { console.error(e); process.exit(1); });

What replaced what

Removed Replacement
NativeApp (159 lines) + createApp Rollup.run in the binding
App interface RollupContext, a Pick of Rollup
addAdvanceHandler / broadcastAdvanceRequests chain() / broadcast() — 47 lines total
app.createNotice(…) etc. rollup.emitNotice(…) etc.

@deroll/app is deleted. @deroll/core is now 47 lines of composition plus 75 of type re-exports, with no runtime dependency on the binding — it imports only types.

Three things that came out better than expected

The router's wiring dependency disappears. createRouter({ app }) existed only so the router could call app.createReport, forcing a create-app → create-router-with-app → register-on-app ordering. run passes the rollup as the handler's second argument, so createRouter() now takes nothing.

RollupContext is a better contract, not just a testing workaround. It exposes the emit methods plus progress/gio while omitting finish, run, close and the merkle helpers — so a handler can no longer call finish() mid-request and desynchronize the loop, which passing the raw Rollup would allow. Being a structural Pick rather than the class (which carries a #private nominal brand) also means tests pass a plain object: @deroll/router dropped vitest-mock-extended entirely.

Composition is testable without a device. packages/app/core/__tests__/compose.test.ts adds 8 tests that load no native addon at all — the file imports only types, so nothing pulls the binding in. That includes a test asserting a handler exception propagates and that later handlers don't run, which is the property that makes chain need no try/catch.

Docs

Migrated in full — Vocs typechecks every snippet, so they could not be left behind.

  • Deleted (7): createApp, addAdvanceHandler, addInspectHandler, createNotice, createReport, createVoucher, createDelegateCallVoucher.
  • Added (3): chain and broadcast reference pages, and a single Outputs page replacing the four create* pages. It keeps the conceptual content — when each output is available, why reports survive a rejection, what a delegate-call voucher is for — since those are Cartesi concepts rather than binding API, and deferring them to @cartesi/rollup would have lost real material.
  • Rewritten: application.mdx → "The Rollup Loop", overview.mdx → "Core", both handler pages, quick-start, and the migration guide (new sections for composing handlers, createRouter losing its app, and registerExceptionemitException). Sidebar updated.

CLAUDE.md records the new architecture and corrects the create-app entry: templates come from apps/examples/src/ in this repo on prerelease/v2 (src/index.ts:76), not from cartesi/application-templates. So the examples are the scaffolded templates and move together — create-app needed only a one-line dependency change, since @cartesi/rollup was already a direct dependency of scaffolded apps for esbuild bundling.

Known regression, deliberate

Rollup.run returns Promise<never>: its finish call sits outside the try, so exhausting CMT_INPUTS on the host rejects rather than resolving. app.start() used to convert that into a clean exit. All seven dev:* scripts now exit 1 on a successful run.

This was an explicit call — host-mode behavior belongs in @cartesi/rollup, not in a deroll shim. The upstream fix is small: move finish() inside the try, or resolve on -ENODATA, plus a decision about whether run() should ever terminate.

Related pre-existing bug, unchanged by this PR and also upstream: the libcmt mock cannot revert, so any rejected request fails with -ENOSYS (io-mock.c:200). This is why the router and walletRouter examples exit 1 under the mock — 0.bin is an advance input and neither registers an advance handler. Verified identical on #203's branch, so it is not a regression here.

Verification

  • bun run build — 9/9 packages including @deroll/docs, with every twoslash snippet typechecking. Lint clean. tsc --noEmit on examples clean.
  • 43 tests pass (8 compose, 5 router, 30 wallet).
  • minimal and echo run end-to-end against the real binding and mock; echo emits a correct Notice(bytes) output (c258d6e5 selector).
  • Rebased onto the current prerelease/v2, same base as refactor(core,app)!: align with @cartesi/rollup, drop the HTTP-era surface #203.

Open question

@deroll/core is now ~25 lines of runtime code. It is worth asking whether chain/broadcast should live in @cartesi/rollup as a convenience alongside run itself — at which point @deroll/core disappears and the app pillar becomes just @deroll/wallet and @deroll/router.

One thing would need resolving upstream first: run treats void as accept (!== false), while chain requires a strict boolean and treats falsy as declined. Moving chain upstream unchanged would mean the same handler means opposite things depending on whether it is passed to run directly or through chain — an inconsistency inside one package, which is worse than the current split across two.


Generated by Claude Code

claude added 5 commits August 13, 2026 12:36
…rface

@deroll/core restated the rollup protocol in types that had drifted from the
binding, and nine of them were leftovers of the removed Rollup HTTP Server
transport with no consumers at all. @deroll/app wrapped a deliberately
synchronous binding in an asynchronous facade.

Keep both packages — the multi-handler loop and the interface that lets wallet
and router plug into it are what deroll actually adds over Rollup.run(), which
takes exactly one handler of each kind — but stop them restating the protocol.

- core re-exports AdvanceRequest, InspectRequest, RollupRequest, Voucher,
  DelegateCallVoucher, BytesLike, AddressLike, U256Like and Hex from
  @cartesi/rollup instead of declaring its own copies, so they cannot drift.
  @cartesi/rollup becomes a peer dependency: the rollup device allows one open
  handle per process, so the tree must resolve to a single copy. viem is no
  longer a dependency of core.
- delete RollupAdvanceRequest, RollupInspectRequest, RequestType, RequestData,
  RequestMetadata, NoticeResponse, ReportResponse and VoucherResponse.
- flatten the advance request: AdvanceRequestData/AdvanceRequestMetadata become
  AdvanceRequest, carrying the metadata fields directly, which also drops the
  destructuring conversion in the request loop.
- add registerException to App. NativeApp always implemented it, but it was
  missing from the interface createApp returns, so it was unreachable.
- a handler exception now rejects the request and is emitted as a report,
  skipping the remaining handlers, instead of going to stderr and letting the
  next handler write state on top of a partially applied one. Reports survive a
  rejection, so the failure is visible from outside the machine.
- outputs are synchronous, and notice/report payloads are passed directly
  rather than wrapped in an object. Handlers may still be async but need not be;
  the wallet and router handlers are now synchronous.

Router.handler also reads its query straight out of the request Buffer instead
of round-tripping through viem's toBytes, which only decoded correctly by
accident.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ud9jF1bYpLMhJaJU8q1KDX
The string union was the last of the Rollup HTTP Server's vocabulary left in
the API. deroll v1 did not merely mirror it — it passed the handler's return
value straight into the transport:

    const { data } = await this.POST("/finish", { body: { status }, ... });
    status = await this.handleAdvance(...);

`RequestHandlerResult` was the `/finish` request body's `status` enum. With the
HTTP transport gone, handlers return a boolean, matching `finish({ accept })`
in the binding.

The words were misleading anyway. A handler returning "reject" never rejected
the input, it only declined it and passed it to the next handler; the input is
rejected only when no handler accepted. `true`/`false` describes that chain
honestly, where "accept"/"reject" claimed an authority a single handler in a
chain does not have.

The type is a strict boolean with no `void`: `Rollup.run` in the binding
accepts a request unless a handler returns false, whereas deroll rejects unless
a handler opts in, so a handler that falls off its end must be a type error
rather than a silent accept.

Also tightens the wallet deposit assertions from `toBeTruthy()` to
`toBe(true)`. They were vacuous before — both "accept" and "reject" are truthy
strings, so they passed whatever the handler returned.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ud9jF1bYpLMhJaJU8q1KDX
Every twoslash snippet is typechecked by the vocs build, so the docs are
migrated in full rather than left behind the code.

The API reference for the App wrapper is gone, since the methods it documented
now belong to @cartesi/rollup:

- createApp, addAdvanceHandler and addInspectHandler are deleted; the loop and
  its handlers are covered by "The Rollup Loop" and the handler pages.
- the four create*Voucher/Notice/Report pages are replaced by a single Outputs
  page, which keeps the conceptual content (when to use each output, what a
  delegate-call voucher is for, why reports survive a rejection) and documents
  them as rollup.emit* calls.
- chain and broadcast get reference pages, being what deroll now actually owns.

The migration guide gains sections for registering several handlers, for
createRouter losing its app, and for registerException becoming emitException.

CLAUDE.md records the new architecture, corrects the create-app entry (templates
come from apps/examples in this repo, not from cartesi/application-templates)
and notes the two upstream rough edges: Rollup.run never resolving on the host,
and the libcmt mock being unable to revert a rejected request.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ud9jF1bYpLMhJaJU8q1KDX
@changeset-bot

changeset-bot Bot commented Aug 13, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 0396248

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 3 packages
Name Type
@deroll/create-app Minor
@deroll/router Minor
@deroll/wallet Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@vercel

vercel Bot commented Aug 13, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
deroll Ready Ready Preview Aug 13, 2026 6:42pm
deroll-explorer Ready Ready Preview Aug 13, 2026 6:42pm

@cartesi/rollup 1.0.0-alpha.1 adds `chain` and `broadcast`, the handler types,
and a `run` that resolves when the host mock's inputs are exhausted. That was
the entire content of @deroll/core, so the package is removed rather than left
re-exporting someone else's functions.

Upstream chose the strict-boolean contract for composed handlers and kept
`boolean | void` for the handlers `run` takes directly, which is the split this
repo argued for: a `run` handler decides an input's fate alone, so no answer can
mean accept, while a composed handler only makes a claim and a missing answer
has no sensible default.

- wallet and router take their types from @cartesi/rollup, as a peer dependency.
- Router.handler returns whether a route matched, so it composes with `chain` and
  an unmatched query falls through. Its rollup parameter is narrowed to
  Pick<Rollup, "emitReport">, which keeps it satisfiable by a plain object in
  tests since Rollup carries a #private brand.
- create-app no longer scaffolds @deroll/core; @cartesi/rollup was already a
  direct dependency of generated apps for esbuild bundling.
- the chain/broadcast/Core doc pages are deleted, being reference material for
  another package now; composing handlers is covered in the handler pages.

All seven dev:* examples exit 0 against the mock again, including router and
walletRouter, which previously died on the mock's -ENOSYS when a rejected input
was also the last one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ud9jF1bYpLMhJaJU8q1KDX
@tuler
tuler merged commit 8819916 into prerelease/v2 Aug 13, 2026
4 checks passed
@tuler
tuler deleted the claude/chain-prototype branch August 13, 2026 18:46
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.

2 participants