A proposed local-first Rust library and CLI for organising Hungarian government correspondence: cases, submissions, attachments, and receipts.
Status: alpha, which is the stage the tool itself reports. The
executable reports its capabilities, creates a local archive, imports files
into a content-addressed store that preserves the original bytes, organises
what it holds into cases and submissions, records receipts together with the
user's own assertions about whether a receipt relates to a submission, checks
a whole archive against what its records claim without changing anything,
reads one stored record back with what relates to it, copies one case, or a
whole archive, out as plain files and reads such a copy
back in, keeps a case record current, narrows a restored archive's permissions back
to owner-only, deletes a case when asked, removing stored bytes only on an
explicit --purge, searches the user's own record text, computes the media
type and the byte length of every stored object when asked, writes the agent
skill document it carries, and generates its own shell completions and man
page. Automatic matching, receipt parsing,
KRX and .es3 handling, editing of a stored record other than the case record
case update rewrites, deleting a single submission or receipt, deleting an
archive, signature verification, and government delivery are not implemented.
There is no published release.
openPapir is an independent open-source project. It is not the government's e-Papír service, is not affiliated with its operators, and does not submit correspondence to it.
Build from this checkout with Rust 1.88 or newer:
cargo run --locked -p openpapir-cli -- --help
cargo run --locked -p openpapir-cli -- --version
cargo run --locked -p openpapir-cli -- capabilities --json
cargo run --locked -p openpapir-cli -- archive init ./my-archive --json
cargo run --locked -p openpapir-cli -- import --archive ./my-archive ./a-file --json
cargo run --locked -p openpapir-cli -- case create --archive ./my-archive --title "Tax matter" --json
cargo run --locked -p openpapir-cli -- case list --archive ./my-archive --status open --tag tax --json
cargo run --locked -p openpapir-cli -- case update --archive ./my-archive <case-id> --status closed --tag appeal --json
cargo run --locked -p openpapir-cli -- submission add --archive ./my-archive --case <case-id> --description "Posted the form." --file ./a-file --json
cargo run --locked -p openpapir-cli -- import --archive ./my-archive ./a-file --case <case-id> --description "Posted the form." --json
cargo run --locked -p openpapir-cli -- case show --archive ./my-archive <case-id> --json
cargo run --locked -p openpapir-cli -- receipt add --archive ./my-archive --artefact sha256:<digest> --label "Envelope" --json
cargo run --locked -p openpapir-cli -- receipt list --archive ./my-archive --json
cargo run --locked -p openpapir-cli -- association create --archive ./my-archive --receipt <receipt-id> --outcome candidate --candidate "<submission-id>:moderate:The reference matches." --json
cargo run --locked -p openpapir-cli -- association list --archive ./my-archive --receipt <receipt-id> --json
cargo run --locked -p openpapir-cli -- submission show --archive ./my-archive <submission-id> --json
cargo run --locked -p openpapir-cli -- receipt show --archive ./my-archive <receipt-id> --json
cargo run --locked -p openpapir-cli -- association show --archive ./my-archive <association-id> --json
cargo run --locked -p openpapir-cli -- archive check --archive ./my-archive --json
cargo run --locked -p openpapir-cli -- archive status --archive ./my-archive --json
cargo run --locked -p openpapir-cli -- case export --archive ./my-archive --case <case-id> --to ./my-export --json
cargo run --locked -p openpapir-cli -- case import --archive ./my-archive --from ./my-export --json
cargo run --locked -p openpapir-cli -- archive export --archive ./my-archive --to ./my-archive-export --json
cargo run --locked -p openpapir-cli -- archive import --archive ./my-archive --from ./my-archive-export --json
cargo run --locked -p openpapir-cli -- archive repair-permissions --archive ./my-archive --json
cargo run --locked -p openpapir-cli -- archive derive --archive ./my-archive --json
cargo run --locked -p openpapir-cli -- case delete --archive ./my-archive --case <case-id> --purge --json
cargo run --locked -p openpapir-cli -- skill
cargo run --locked -p openpapir-cli -- completions bash
cargo run --locked -p openpapir-cli -- manpage
cargo run --locked -p openpapir-cli -- search --archive ./my-archive "tax" --jsonThe capabilities command reports the current implementation honestly:
{
"schema_version": 1,
"ok": true,
"command": "capabilities",
"data": {
"project": "openPapir",
"stage": "alpha",
"operations": [
"archive.init",
"import",
"case.create",
"case.list",
"case.show",
"submission.add",
"receipt.add",
"receipt.list",
"association.create",
"association.list",
"association.retire",
"archive.check",
"archive.status",
"case.export",
"case.import",
"archive.repair_permissions",
"case.delete",
"skill",
"case.update",
"submission.show",
"receipt.show",
"association.show",
"completions",
"manpage",
"archive.export",
"archive.import",
"archive.derive",
"search"
]
},
"verified": false
}The operation list names exactly what is implemented today, one sentence
each. All but skill, completions, and manpage can process input:
archive initcreates an archive in an existing, empty directory, writing the marker first and refusing to adopt anything else.importstores each file's bytes unchanged, records one import event per input, and reports a re-import of the same bytes as a duplicate rather than an error. With--caseand--descriptionit also records one submission naming every file it imported, under the same writer lock.case createrecords one case, the user's own folder of related correspondence, with the status and the tags the user gave it.case listlists the cases that match every filter given:--status, every--tag, and a--querymatched as a case-insensitive substring of the title or notes in one linear scan, with no index. The query is never echoed back.case showshows one case, with its status, tags, and update time, the submissions recorded against it and their artefact references, and the receipts a live association ties to one of those submissions.case updaterewrites one case record in place, keeping its identifier and creation time and reporting the names of the fields it changed. It is the one invocation that rewrites a stored record.submission addrecords one submission the user states they sent, against a case.receipt addrecords that the user believes one stored artefact to be a receipt.receipt listlists every receipt in the archive.association createrecords what the user asserts about one receipt, with one of the outcomesunassociated,candidate,associated, andcontradictory.association listlists one receipt's whole association history, newest first, superseded records included.association retirewithdraws one assertion by writing a record that supersedes it and claims nothing; both records stay, and nothing is edited or removed.submission showshows one submission as it is stored, with every association naming it: the live heads first and the superseded records after them.receipt showshows one receipt as it is stored, with its whole association history, newest first and superseded records included.association showshows one association as it is stored, whether it is the live head of its chain, and the chain it belongs to: what it supersedes and what supersedes it.archive checkre-digests what the store holds and reports counts only; it takes no lock, changes nothing, and a passing check is storage integrity rather than authenticity.archive statussummarises what the archive holds and lists the submissions whose receipt is still worth looking for in the delivery storage, inside the 30-day window the operator's help page describes. It takes no lock, changes nothing, and states no delivery, no receipt by an authority, and no legal effect.case exportcopies one case out as plain files, the original bytes named by their digest plus readable JSON records and a manifest, without changing the archive, and re-digests every copy.case importreads such a copy back into an archive, checked against the export's manifest before anything is written; a record keeps the identifier it had, an object or a record already there is not an error, and importing one export twice leaves the same archive.archive exportcopies the whole archive out in the same shape, every object and every record of every kind, with the archive marker beside the manifest so the schema version travels with the copy.archive importreads such a copy back, restoring every case in it as one set: all of it or none of it. Each import reads its own kind of export and refuses the other's.archive repair-permissionsnarrows a restored archive back to owner-only; it never widens anything.archive derivecomputes, for every stored object, its byte length and a media type from a closed table decided by the first 4 KiB of bytes. It runs only when asked, the records are disposable and never authoritative, and a media type states what the leading bytes look like and never that a file is a receipt, is authentic, or was delivered.case deleteis the one destructive command: it removes a case and its submissions, and the receipts and associations tied only to them, but it removes no stored bytes unless--purgeis given, and even then only bytes nothing that remains references. It reports counts and record kinds, never a digest or a filename, records no deletion anywhere, and does not erase data from the storage medium.skillwrites the embedded agent skill document to stdout byte for byte and adds nothing. It takes no file and no--json, touches no archive, and processes no input.completionswrites one shell's completion script to stdout, forbash,zsh,fish,powershell, orelvish. A value outside those five is a usage refusal, exit2, printed as the argument parser's own usage text.manpagewrites the man page for the whole command tree to stdout as one roff stream: the page foropenpapirfirst, then one page for each subcommand. Both are generated from the same command definition the parser uses, take no file and no--json, and touch no archive.searchreports where a word is in the user's own record text: case titles, notes, and tags, submission descriptions, receipt labels, an association's own statement, and the record identifiers. It reads no stored object, no original filename, and nothing derived, and a hit names the field it matched rather than repeating the text.
Every record is the user's own local record: openPapir sends nothing and forms
no opinion of its own about what an artefact says, so a submission is what the
user states they sent, a
receipt is an artefact the user believes to be one, an association is what the
user asserts about it, and a date they supply is stored verbatim and never
read as a delivery or receipt date. verified: false means no cryptographic
verification was performed: a digest identifies bytes, and says nothing about
authenticity or delivery. The full contract, including the error codes and
exit codes, is in architecture and CLI contract.
| Responsibility | State |
|---|---|
| Preserve original submission and receipt bytes in a local case archive. | Implemented by import and the write-once artefact store. |
| Associate submissions, attachments, and receipts with explicit provenance. | Implemented for the user's own assertions; automatic matching and receipt parsing are not implemented, and the only derived metadata is the media type and the byte length archive derive computes on request. |
| Expose case information through a CLI and structured JSON. | Implemented by the list, show, export, and search commands; search and case list --query read the user's own record text only, and searching the content of stored artefacts is not implemented. |
Delegate KRX container processing to openKRX and .es3 processing to openSzigno. |
Not implemented. Neither sibling project is a build dependency of this project, and neither parser is copied into it. |
| Delegated authenticity verification, reported with its exact scope and trust context. | Not implemented. No cryptographic check of any kind exists here. |
| Government submission and delivery. | Not implemented and out of scope for now. |
Importing a receipt, matching it to a submission, and verifying its cryptographic authenticity remain separate records: an association changes nothing about the artefact and creates no verification result. Matching alone must never assert legal effect or successful delivery.
Every command has two modes, and they are the same contract seen twice. With
--json exactly one object reaches stdout and nothing reaches stderr, so a
program reads a result without parsing prose. Branch on the exit code first,
which carries the error's bucket and nothing else, then on error.code, which
is stable within a schema_version; message wording is not. verified is
false in every envelope this build emits.
$ openpapir receipt add --archive ./archive --artefact sha256:5e0d7d51... \
--label "Envelope from the post" --json
{"schema_version":1,"ok":true,"command":"receipt.add","data":{"receipt":{"archive_schema_version":1,"artefact_digest":"sha256:5e0d7d511a1df60c20dc2d589f7f1d13b87019603a724a938c71e81a8d7e1dac","created_at":"2026-09-09T16:10:57Z","id":"71df7d0ed619cc29d4478e0db8d014b6","import_event_id":"db11ed295d6b8d7c3edebf4ab251de78","label":"Envelope from the post","record_kind":"receipt"}},"verified":false}The binary carries an agent skill document describing when to reach for
openPapir, every command's exact invocation, the envelope, the exit codes, the
privacy rule, and the boundary between imported, matched, and
authenticity-verified. openpapir skill writes it to stdout byte for byte and
nothing else, so installing it needs no checkout:
mkdir -p .claude/skills/openpapir
openpapir skill > .claude/skills/openpapir/SKILL.mdUse .codex/skills/openpapir/ for Codex, or ~/.claude/skills/openpapir/ to
install it for every project instead of one. The same bytes are committed as
the agent skill.
Both modes are pinned byte for byte by the captured output under tests/golden, so a change to what a caller parses is a reviewed change rather than an accident.
The binary generates both from the same command definition its parser uses, so neither can fall behind the commands it has. Both write to stdout, so the destination is your own redirection:
openpapir completions bash > ~/.local/share/bash-completion/completions/openpapir
openpapir completions zsh > ~/.local/share/zsh/site-functions/_openpapir
openpapir completions fish > ~/.config/fish/completions/openpapir.fish
openpapir manpage > ~/.local/share/man/man1/openpapir.1powershell and elvish are offered as well; a shell outside the five is a
usage refusal. Start a new shell after installing the script. The man stream
holds the page for openpapir and one page for each subcommand, so
man openpapir shows the whole tool, and openpapir manpage | man -l - reads
it without installing anything.
A release archive carries the same files, so installing from one is a copy
instead of a command: completions/ holds a script per shell under the name
that shell looks for (openpapir.bash, _openpapir, openpapir.fish,
_openpapir.ps1, openpapir.elv) and man/man1/openpapir.1 holds the page,
each written during packaging by the archived binary itself, so both match
that binary. Copy the script for your shell to the directory the commands
above redirect to, and the page to a directory on your MANPATH. Nothing has
been released yet, so there is no archive to install from; see
releasing.
Without --json the same command prints lines meant to be read, with the
result on stdout and any warning or error on stderr, so diagnostic text never
shares stdout with a result. The lines carry the user's own titles, labels, and
statements, the identifiers and digests openPapir minted, and no path,
filename, or payload byte.
$ openpapir receipt add --archive ./archive --artefact sha256:5e0d7d51... \
--label "Envelope from the post"
Receipt 8bbfed851cf9dedefc80442ca4f80a0d, recorded 2026-09-09T16:10:57Z.
Artefact: sha256:5e0d7d511a1df60c20dc2d589f7f1d13b87019603a724a938c71e81a8d7e1dac
Import event: db11ed295d6b8d7c3edebf4ab251de78
Label: Envelope from the post
These are the user's own assertions. openPapir checked nothing about the file and reports no delivery, authenticity, or legal effect.The closing line is not decoration. A receipt is a file the user believes to be one, an association is what the user asserts about it, and neither is a verification: openPapir reads no artefact bytes and opens no socket.
These lines are English only, because their careful wording and the argument parser's own text would each need a native reviewer before every release. The JSON form remains the scripting contract for anyone who reads a result programmatically. Human output language records that decision, together with the compiled-in message table a demand for Hungarian output would go through.
The end-to-end guide walks one matter from archive init to
case delete on a synthetic example, with the real output of every command.
The specification index is the single entry point to
the project's scope, what is implemented today, which designs are decided but
not built, and which contracts are still blocked. The
documentation index gives every document and root policy file
a one-line purpose, including
the agent skill and the
golden output contract.
The workspace contains openpapir-core and openpapir-cli. Both crates are
unpublished, use Rust edition 2024, and are licensed under MIT.
./scripts/check.shPull requests target develop; master is reserved for stable releases.
See contributing, whose Documentation section states how
the documents are kept correct, and security. The
roadmap records the bounded discovery work each format
decision waits on, and its fifth milestone, the local organiser, records the
order of that work: restoring from an export, case lifecycle
and search, the receipt-retrieval reminder, the entangled-deletion remedy,
generative testing, a release pipeline and a Windows-target lint, shell
completions and man pages, an end-to-end user guide, a whole-archive export,
derived metadata on explicit request, and encrypted backup at rest. Every item
but the last is implemented; encrypted backup at rest is blocked on widening
the licence allow list, per the dependency review recorded in
local archive layout and storage design, which ran
and admitted no candidate.