How a feature is made
This page is for people who want to change Tealhand. It says how a feature moves from an idea to a merged change, the rules that don’t bend, and where to put each piece when you add something new. Read Contributing for the short rules, and Build from source to set up the toolchain.
From idea to merge
Section titled “From idea to merge”A feature is built as one or more work packages. Each one goes through the same steps.
- A contract in the plan. The package is written down in
docs/plan.mdand its details first: what it adds, which crates it may use, what other crates may rely on, the limits it must keep, and the tests it must have. Where a feature touches the wire or the action list, the contract lists every new item, so two packages never append to the same enum at the same time. A decision that is not obvious is added to the decisions log in the plan, with a number that code comments and the threat model can cite. - A branch. The work happens on its own branch, started from the current integration branch (
m1for the first milestone). A pull request stays small and has one topic. - Tests with the code. The tests come in the same change as the code: unit tests in the crate, and an end-to-end test when the feature crosses the phone and the computer (below). A bug fix comes with a test that failed before it.
- One review. A reviewer reads the change against the contract and the hard rules, not only the diff. They look for wrong behaviour, anything unsafe, what breaks under load, risky code without a test, and code that isn’t needed.
- One fix round. The author answers every finding with a change or a reason.
- The full checks. Every check below must pass on Ubuntu and Windows.
- Merge. The branch is merged into the integration branch. If the change touches what a person reads or runs (a command, a limit, an error text, a screen), the matching page on this site changes in the same pull request.
The hard rules
Section titled “The hard rules”- No
unsafein our code. The workspace setsunsafe_code = "forbid". Only two isolated crates may have it, each with a tiny API:tealhand-ffiandtealhand-wincam. They are listed indocs/unsafe.md. If a feature seems to needunsafeanywhere else, stop and open an issue. - Wire types are append-only. postcard encodes an enum variant as its position, so the order is the wire
format. Never reorder or remove a variant of
MsgType,ErrorCode,EventKind,Action,ActionOutputor any other type that crosses the network. Add at the end. A protocol change bumpsCAPABILITY_VERSIONintealhand-protoand adds a line to its history. - Every action is behind a card. Anything that changes something is an action from the action list, and the phone shows a confirmation card before it signs. Reads that don’t change anything need no card.
- Destructive actions never come from chat. Chat means the ask box and the planner. The action list marks where each action may start, and the code that builds a card sets the origin itself.
- Text from outside is data. Web pages, files, program names and agent output are shown, cleaned, and never turned into a command.
- Texts are keyed and follow the copy standard. A text a person reads says what happened, why if known, and what
to do, in plain words. Every phone text is a key in
texts.dart; every Rust text is one string in one place; names go in through placeholders and a sentence is never built from fragments. The rules are indocs/copy-standard.mdin the repository, and the matching error text goes into Error codes. - Keys, tokens, addresses and personal data never go in the repository. Use
relay.example.comand203.0.113.10. - Every parser of bytes from another device has a fuzz target, and a crash you fix becomes a unit test.
How to add an action
Section titled “How to add an action”Say you want a new action, for example one that reads a small value from a computer. Work in this order. Each step has its test.
-
tealhand-actions. Everything about an action is defined here. Append, never reorder:- an
ActionIdvariant (and add it toActionId::ALL) and anActionvariant with its parameters; - its row in the table in
defs.rs: label, help, search words, the highest risk, where it may start (from_chat,from_palette,remote_only,local_only), andcontentif it reaches files, the screen, the clipboard or a shell, which makes it refused while remote control is off; - the rules of its parameters in
validate(checks.rshas the shared ones: names, paths, URLs), andneeds_presence()if it needs the fingerprint; - its card in
card.rs: a title, one plain sentence, a later time (shown only when the action doesn’t happen now), an undo line (shown only when it matters: removing a device, forgetting a phone, shutting down, deleting or replacing a file; empty otherwise) and the button’s word.
The crate’s tests check each table, that the planner’s JSON Schema leaves out anything chat may not start, and that every action round-trips through postcard and JSON.
- an
-
The wire. If the action needs a new answer type, error code or event, append it in
tealhand-protoor toActionOutput, bump the capability version, and add the history line. Add a test that the new items round-trip. -
The card text. The card in
card.rsis the only place its words live. Check it against the copy standard and the length limits. -
The Host. Teach
executeintealhand-host/src/engine.rsto run the action. The Host has already checked the signature, the nonce, the time, the fingerprint and remote control beforeexecuteruns. Add the new error codes’ sentences torefusal_textintealhand-core/src/error.rstoo, so the phone says what to do. Until the Host knows an action, it answersNotSetUp. -
The phone’s core.
tealhand-corecleans every output that comes from a computer before a screen sees it: add the new output tosane_outputinevent.rs, which drops control and invisible characters and caps lengths. Then add the call totealhand-ffiand regenerate the bridge (flutter_rust_bridge_codegen generate, thencargo fmt; seeapps/mobile/README.md). -
The screen. Add the screen or button in
apps/mobile, with every text as a key intexts.dart, and widget tests on the app’s fakes. -
An end-to-end test in
tealhand-e2e. This is the test that proves steps 1 to 6 agree. See below. -
The docs. Add the action to Actions, its error texts to Error codes, and anything new to the glossary.
Run the checks locally
Section titled “Run the checks locally”CI runs these on every pull request and on every push to main, on Ubuntu and Windows (not on pushes to other
branches, and not for changes that only touch docs, the sites or design/). So run them before you push. One
command runs all of them, and the Android and Flutter ones too, in CI’s order, and stops at the first failure:
scripts/ci-local.shIt prints one line per step and a final PASS or FAIL. It skips the Android step (with a note) when cargo-ndk
or ANDROID_NDK_HOME is missing, and the Flutter steps when flutter is missing; it works in Git Bash on Windows
and in bash on Linux. Push only when it is green. The steps one by one, from the repository root:
cargo fmt --checkcargo clippy --locked --workspace --all-targets -- -D warningscargo test --locked --workspacecargo deny --locked checkcargo audit --file Cargo.lockbash scripts/geiger-check.shcargo deny, cargo audit and cargo geiger are pinned in CI to cargo-deny 0.20.2, cargo-audit 0.22.2 and
cargo-geiger 0.14.0; install them with cargo install --locked <name>@<version>.
If you touch the phone bridge or the app, CI also runs:
cargo ndk -t arm64-v8a clippy --locked -p tealhand-ffi --all-targets -- -D warningscargo ndk -t arm64-v8a build --locked -p tealhand-ffi --releasecargo build --locked -p tealhand-fficd apps/mobileflutter pub get --enforce-lockfileflutter analyze --fatal-infosflutter testThe first two need cargo-ndk 4.1.2 and an Android NDK (ANDROID_NDK_HOME). The Flutter steps use Flutter 3.47.7.
To run just one crate’s tests while you work: cargo test -p tealhand-files, and so on.
The end-to-end crate
Section titled “The end-to-end crate”crates/tealhand-e2e has only tests. They run the real phone core (tealhand-core) against the real
Tealhand Host (tealhand-host) on one computer, over the local network, with the relay turned off on both sides.
Before it existed, each side was tested only against a stand-in for the other, so a mistake in how they fit could
pass every test.
- What is faked: the Android Keystore (a test signer with real P-256 keys, and an optional fingerprint delay), the computer’s programs and GPU (a fake probe) and the power helper (it records its calls and shuts nothing down). Time is real, with a short rule tick and a 2-second notice in place of 5 and 60.
- The harness is in
tests/common/mod.rs:Pc::new()starts a Host in a temporary folder,Phone::new(n)opens a core, and each test uses its own random keys so tests running side by side never find each other by mDNS. tests/core_host.rscovers connecting, status, cards, rules, the notice, the roster push and forgetting.tests/files.rscovers the shared folders, browsing in pages, byte-exact reads and the playback proxy, uploads and conflicts, new folder, rename and delete, a read-only folder, a share removed mid-session, remote control off, the address rule for downloads, and a phone that breaks the stream layout.- To add a flow, build a
Pcand aPhone, connect them, do what a person would do through the core, and assert both what the phone sees and what the Host did (the files on disk, the journal).
cargo test --locked -p tealhand-e2e