茶封筒

How Chabuto works

Chabuto’s theory of operation, rendered from the architecture and security documents kept beside its source — the same pages its authors read, never a second copy written for the site.

Chabuto architecture

Chabuto is a small domain application over the read-only Autarky platform. Autarky owns the executable envelope, canonical-island validation, attachment bytes, encryption, save/open, recovery, whole-file operation gate, update verification, and browser-facing primitives. Chabuto owns what an archive means and how that meaning is presented.

Composition boundary

  • profile.mjs supplies the app id, canonical format, file naming, material bounds, update location, and public release anchor compiled into the app.
  • src/app.ts is the composition root. It joins platform services to Chabuto's controllers and is the sole path for New or replacement of an open archive. It installs window.chabuto and window.chabutoReady from the shape src/public-api.ts owns; the automation API is that contract's page.
  • src/archive-record.ts owns Chabuto's complete stored domain vocabulary.
  • src/attachments-controller.ts joins that record to the platform manifest and commits both sides of an attachment edit as one undoable step.
  • src/localization-controller.ts owns the interface language: it captures the shell's English once, activates a shipped language record, and resolves a source string through the roster binding the shell's autarky-language-roster metas carry. The catalogs and the rule a translation must satisfy are @jimae/family/i18n's; an archive's own text is never translated, and the chosen language is the reader's and is not stored.
  • src/shell.html, src/help.html, and the remaining controllers are projections of the open archive. scripts/ exists only to build and verify the standalone artifact.

No Chabuto module redefines a platform contract. A contract change is made in Autarky, released as a platform tag, and synced into this repository.

Canonical record

The platform's canonical island has five fields: format, version, metadata, domain, and manifest. For Chabuto:

  • profile.mjs supplies autarky/chabuto as the canonical format; the platform serializer's format-version constant is authoritative for the version Chabuto writes. This stored format is independent of the application release number.
  • metadata is Chabuto JSON describing the archive: title and note.
  • domain is Chabuto JSON keyed by attachment id. Each record carries name, declared type, description, folder, importedAt, and optional modifiedAt.
  • manifest is the platform's byte ledger. Each entry contains only id, compressed, expandedSize, storedSize, and sha256; it assigns no name or meaning to a payload.

The platform treats metadata and domain as bounded opaque strings. Both are inside the canonical island and therefore encrypted with it. Payload blocks remain separate and are sealed under the same file key when encryption is on. Names, folders, descriptions, and the archive note never leak into the public shell.

src/archive-record.ts provides the only readers and writers for the client regions. Writers use a fixed key order and omit empty values so one logical record has one byte representation; island-byte comparison can then determine dirtiness. Invalid optional record data degrades to an empty record rather than making verified payload bytes inaccessible. A payload without readable domain metadata remains exportable under its id.

A folder is a separate field, not a prefix encoded into a file name. Intake paths are split once as files enter; afterward a name may contain / as an ordinary character. Folder paths discard empty, . and .. segments.

A folder therefore exists only through the files that name it, and an empty folder is not a state an archive can be in. That is settled rather than owed: holding one would mean a second list inside the canonical record whose entire content is scaffolding, with its own reconciliation, encoding and undo rules against the folders the files already state. In-file Help tells the reader the same thing in the same breath as the folders themselves.

Attachment ceilings

The per-payload and per-file byte ceilings Chabuto enforces are the platform's, in platform/src/attachment-limits.ts, and their numbers are measurements taken against Wakigaki on 2026-07-31 rather than against Chabuto. They are inherited deliberately, because what they measure is the engine and not the application: the maximum JavaScript string, the Base64 primitives pinned to it, and the fact that one byte past that string file.text() resolves with "" instead of throwing — so a too-large file opens empty and the reader's next save destroys their own work.

Chabuto meets those limits on the same terms. Open reads the whole file as one string through the platform's own path, payloads are the same Base64 blocks in the same single-document HTML, and the arithmetic deriving the shipped caps reserves 1 MiB for the shell wrapped around them — where Chabuto's hard artifact ceiling is 512 KiB, enforced by npm run audit:size, so the reserve holds for Chabuto with a factor of two Wakigaki does not have. Against the smallest measured engine, Chromium's 536,870,888-character string, the total stored cap keeps a safety divisor near four and the per-file cap near twelve; the expanded-bytes cap keeps about 1.5 against Chromium's atob output, which is the tightest of them. WebKit measured roughly 3.9 times roomier.

Firefox is unmeasured on either application, for the reason the browser matrix records in family/test/engines.mjs: Playwright's Firefox does not start on the maintainer's machine. It is the one engine that could still move these numbers, and re-measuring belongs to the first host where it launches; apps/wakigaki/scripts/dev-attachment-limits.mjs reproduces the arithmetic.

Runtime state

Canonical state lives in the current ManifestStore and AutarkySession. Controllers obtain that session through an accessor so Open, New, or restore cannot leave them holding a stale document. An archive's identity is the platform's island field: New mints one, the shipped seed has none and files under the seed sentinel until its first save mints one, and Open, Save, Save as, copies, encryption, recovery, and version history carry it unchanged, so records follow an archive across reopening. UI state such as collapsed folders is a projection: it is neither serialized nor undoable. Archive title and note edits, file metadata edits, ordering, and folder moves are canonical mutations and enter history through one commit seam.

All whole-file changes use the platform operation gate and serializer. Open internalizes only an incoming file's validated canonical state and payloads; the running Chabuto shell and bundled capabilities remain those of the trusted application. The security delta owns the one exception to opaque attachment handling: Chabuto's restricted Markdown projection.

Chabuto's automation API

Chabuto installs window.chabuto, an object for driving the application from a script instead of the controls. src/public-api.ts owns its shape as the exported ChabutoApi interface; the family convention it obeys — readiness, version fields, detached reads, canonical mutation paths, outcomes rather than booleans — is docs/AUTOMATION.md and is not repeated here.

Nothing an archive carries is ever executed. This is a surface the outside drives Chabuto through, not a macro facility inside the file.

Readiness

``js const chabuto = await window.chabutoReady; const names = chabuto.attachments.list().map((entry) => entry.name); if (!chabuto.isDirty()) await chabuto.attachments.add( "note.txt", new TextEncoder().encode("hello"), "text/plain" ); ``

window.chabutoReady resolves to the same object as window.chabuto, once the archive has been read, the controllers wired, any recovery offer made, and the startup cover taken down. It rejects with the reason startup failed — the reason the cover then shows — and in that case window.chabuto is never installed at all.

window.chabuto itself still appears earlier in startup. Nothing in the build reads it there; what keeps it early is the browser suite, whose files wait for the property before they drive anything. That is unchanged, and it is why the promise rather than the property is the boundary worth waiting on: the property appearing says the object exists, not that the archive is usable, and it never appears at all when startup failed.

Versions

apiVersion is a number and starts at 1 with this page. appVersion is Chabuto's release version, format is autarky/chabuto, and formatVersion is the canonical island version this build reads and writes. version is retained, deprecated, and still means exactly what it meant before the split: the application version. Read appVersion instead.

Reading the archive

title(), identity() and isDirty() describe the open archive; identity() is null while the archive still carries none. identity() and isDirty() throw when no archive is open — which cannot happen in an ordinary session, Chabuto always has one, but is worth a real error rather than an invented answer. title() does not: it is the same question the topbar asks and answers what an unnamed archive is called.

attachments.list() returns the manifest joined to Chabuto's own record of what those files are, as detached copies, and attachments.exportBytes(id) resolves to a copy of the verified bytes. Writing into either changes nothing the archive holds. attachments.canOpen(id) says whether the file selects one of the fixed passive viewers.

history.pending() resolves to the unsaved copy filed for this archive or null, and history.versions() to the automatic versions kept for it, newest first. Both read the recovery store and neither restores anything.

Changing the archive

attachments.add(name, bytes, type) resolves to the new file's id, and rejects when the payload exceeds the archive's bounds or when the archive it was measured against closed while it was being prepared. remove(id) and describe(id, text) return whether they changed anything: false for an id the archive does not carry, and false for a description that already said that, which is the same silence an untouched field gets. undo() and redo() return whether a step was applied.

Every one of these takes the controller the buttons take and resolves the session when it runs, so the list repaints, the archive is marked dirty, the step joins the same history, and whatever a click would arm — the save behaviour, any safety copy that path schedules — is armed the same way.

attachments.open(id) is the controller's own viewing path: it resolves true when the file is showing in its own tab, false when the browser refused the tab, and throws for an id the archive does not carry or one whose type has no passive viewer. It is installed here because AttachmentsController.publicApi() is installed whole rather than copied method by method, which is what had let a declared open() and an installed surface without one drift apart.

An id the archive does not carry is a refusal from a question — canOpen and exportBytes throw — and merely false from an edit that would have had nothing to do. Asking about a file that is not here is a mistake worth reporting; failing to remove one is not.

Whole-file operations

new(), save(), saveAs() and saveACopy() resolve to the platform's outcome. new() asks before discarding unsaved work and answers { status: "cancelled" } when the question is refused, having changed nothing. The saves answer cancelled when the file picker is dismissed, { status: "failed", error } when the write itself failed or no archive was open, and completed with the save report — filename, target, mode, identity, revision — when the bytes landed. A failure is never returned as a cancellation.

open() is the one method that reports nothing: it triggers the shell's Open control and returns undefined. The platform owns Open's confirmation, picker, and failure report inside SerializationController.attach() and offers no awaitable entry into them, and a second copy of that flow in the composition root is the duplication this API exists to prevent. Giving Open an outcome is upstream work in Autarky. A scripted click also carries no user activation, which WebKit requires before it will show a file chooser, so a test that needs the picker clicks the control itself.

Stability

The surface is Chabuto's automation contract, not a product feature: the browser suite is its principal caller, and an incompatible change raises apiVersion and is described here. test/public-api.test.ts holds the installed object to ChabutoApi in both directions, so a member that appears or disappears without a line on this page stops the gate.

Chabuto security delta

Chabuto inherits the complete Autarky security model, including its threat model, cryptography, canonical-state rules, bounded and atomic input handling, operation serialization, trusted-shell save/open, recovery behavior, bundled-material checks, and signed-update requirements. Those rules are normative here. This document records only Chabuto's added assets and exceptions. Report vulnerabilities through the private channel in the platform security document.

Trusted opening

A .chabuto.html file is executable. Launching an untrusted one directly also trusts its shell, which could imitate Chabuto and exfiltrate a password or plaintext. For uncertain provenance, start a trusted Chabuto copy and use Open. That path adopts only validated canonical state and payload bytes; it never adopts the incoming shell, capabilities, or interface.

Archive metadata is confidential state

Chabuto assigns meaning to the platform island's opaque client regions. The small metadata region contains the archive title and note. The domain region maps attachment ids to names, declared types, descriptions, folders, and dates. Both regions are canonical state and are encrypted with the island. The public shell therefore reveals none of an encrypted archive's listing or description. Architecture owns their encoding.

Declared content type is untrusted metadata. It is normalized and used only as a selector into the platform's closed passive-viewing map, where an accepted type maps to the same fixed browser type. Names, extensions, byte recognition, and user metadata cannot add a viewer or switch the selected type. Active types such as HTML and SVG are not viewable. Recognition may warn about a mismatch but cannot change this decision.

Markdown is the sole interpretation exception

text/markdown is the only attachment type Chabuto interprets. The attachment itself is never handed to a browser as markup. src/markdown-viewer.ts builds a new document under four enforced boundaries:

  1. Only its closed element whitelist can be created, and emitted elements receive no attributes.
  2. Author-controlled content enters as text, never HTML.
  3. Links lose their destinations and images become alt text, preventing both navigation and viewer-triggered egress.
  4. The generated document's CSP blocks scripts and network sources; its only allowances are the viewer's inline style and local data images, though the renderer emits no image element.

Hostile-input tests assert the whitelist, markup inertness, destination suppression, and CSP. Adding another interpreted format is a new subsystem and security design, not an extension of this exception. Chabuto currently embeds no subsystem payloads.

Family release anchor

profile.mjs compiles in the public half of the one Jimae family release key and Chabuto's manifest origin. A signed manifest's app id separates Chabuto from the other family artifacts; compromise of the shared private key would compromise all of them. The private half remains outside the repository and CI. Custody and release operations belong to the root release guide.

No public release has been made, so the configured update origin is not live and manual checks currently fail closed. Making it public requires the maintainer's fresh approval.

Launch and the served copy

Family Launch policy owns recognition, signed inventory verification, inherited browser policy, handoff, and offline installation. Every member follows that contract without exceptions.