@miadi/episode-vessel
v0.1.3
Published
The file law of a chronicle episode folder, written once: bounded contained listing, text read and revision-guarded atomic write, ceremony notes and ceremony file attachments. No HTTP, no React, no service URL — a phone, a Next.js route and a test open th
Readme
@miadi/episode-vessel
The file law of a chronicle episode folder, written once.
What an episode folder contains, which of it may be read, which of it may be
written, and how a write is guarded. No HTTP, no React, no service URL, no
medicine-wheel client — so a phone process (miadisabelle/gmtermux), a Next.js
route handler (jgwill/Miadi) and a test all open the same vessel the same way.
npm i @miadi/episode-vesselimport { listEpisodeFiles, readEpisodeText, writeEpisodeText } from "@miadi/episode-vessel"
// MIADI_CHRONICLE_ROOT, or pass { root }
const files = listEpisodeFiles("2026-09-17-episode-349-miadi-conducts-a-ceremony-its-circle-can-enter")
const note = readEpisodeText(episode, "review-claude-1-260918.md")
writeEpisodeText(episode, "review-claude-1-260918.md", edited, note.revision.sha256)CommonJS works too — require() of this ESM build is proven on node 24 on both
gaia and the Android device.
What it guarantees
- Containment. An episode is one folder name directly beneath the chronicle
root — never a path, never a traversal, never a symlink. A file inside it is a
relative path with no
.,..or dot-prefixed segment, resolving after symlink resolution to a real file still inside that folder. - A bounded listing. Depth 3, 200 files, dotfiles and
episode.yamlexcluded. The host'sinclude(relativePath, kind)hook runs before a file consumes the cap, so a hundred capture files cannot hide one artefact. - No lost write.
writeEpisodeTexttakes the sha256 the caller was shown. If the file moved, nothing is written andEpisodeConflictErrorcarries the revision on disk. The write itself is a temp file created withwx, the revision re-checked, thenrename— a reader never sees half a file.
Kinds
| kind | extensions | previewed | editable |
|---|---|---|---|
| text | md, txt, yaml, yml, json | yes, to 512 KiB | yes, unless the path says otherwise |
| document | html, htm, pdf, csv | no | no |
| media | m4a mp3 wav opus aac amr mp4 webm mov mid ogg | by byte range, by the host | no |
document is listed and downloadable but never rendered inline. A vessel's HTML
is written by agents; serving it into a page's own origin would make every
artefact a script the reader did not ask for. A host that wants to show it does
so in a sandbox of its own choosing, deliberately.
Never editable through a browser: episode.yaml (the vessel's identity),
guidance documents (AGENTS.md, CLAUDE.md, …), anything under captures/
(bundles carry verified digests — that is @miadi/episode-capture's work) and
anything under ceremonies/ (which has its own verbs).
Guidance documents are listed, marked guidance: true. Hiding a file that
exists is the worse error.
Ceremonies
ceremonies/<id>/ holds two files, and the two verbs that write notes.md are
different on purpose:
seedCeremonyNote— write-once. The minted record of who opened the ceremony. If the circle has already written, it is not overwritten; the reason comes back and a correction goes beside it.writeCeremonyNote(content, { expectedRevision })— the circle's working notes, overwritten under the same guard as any other text file.
listCeremonyFiles / attachFileToCeremony / detachFileFromCeremony keep
files.json: which artefacts of this vessel the ceremony was held about. The
file must already exist in the same vessel — a ceremony cannot be told it was
held about something that is not there.
A ceremony id is either form the wheel emits: a UUID
(603bfaa1-958d-42a1-bbdf-a25fc2cfdaae) or a minted id
(ceremony:1789863312388:mgj2x). Accepting only one made half the wheel's
ceremonies unwritable, which is how this package found out.
Permissions
@miadi/episode-vessel/permissions is a role → capability map as plain data. No
identity, no token, no session, no dependency.
import { capabilitiesFor } from "@miadi/episode-vessel/permissions"
const can = capabilitiesFor(person.role, { grants: communityConfig.role_grants })The capability object decides what is offered. The host's gate decides what is
allowed. A surface rendering an edit button for a role without edit is a
bug; a route writing because the client claimed a capability is a
vulnerability. Both checks exist and neither replaces the other.
Provenance
Lifted from gmtermux:web/pixel/episodes/store.js, the one place this law had
been written and proven, and from Miadi:lib/chronicle-vessel.ts, which had
copied half of it by hand. Ref jgwill/Miadi#651, and jgwill/Miadi#644 for the
same measurement applied to captures.
