Skip to content

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.

A feature is built as one or more work packages. Each one goes through the same steps.

  1. A contract in the plan. The package is written down in docs/plan.md and 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.
  2. A branch. The work happens on its own branch, started from the current integration branch (m1 for the first milestone). A pull request stays small and has one topic.
  3. 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.
  4. 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.
  5. One fix round. The author answers every finding with a change or a reason.
  6. The full checks. Every check below must pass on Ubuntu and Windows.
  7. 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.
  • No unsafe in our code. The workspace sets unsafe_code = "forbid". Only two isolated crates may have it, each with a tiny API: tealhand-ffi and tealhand-wincam. They are listed in docs/unsafe.md. If a feature seems to need unsafe anywhere 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, ActionOutput or any other type that crosses the network. Add at the end. A protocol change bumps CAPABILITY_VERSION in tealhand-proto and 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 in docs/copy-standard.md in 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.com and 203.0.113.10.
  • Every parser of bytes from another device has a fuzz target, and a crash you fix becomes a unit test.

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.

  1. tealhand-actions. Everything about an action is defined here. Append, never reorder:

    • an ActionId variant (and add it to ActionId::ALL) and an Action variant 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), and content if 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.rs has the shared ones: names, paths, URLs), and needs_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.

  2. The wire. If the action needs a new answer type, error code or event, append it in tealhand-proto or to ActionOutput, bump the capability version, and add the history line. Add a test that the new items round-trip.

  3. The card text. The card in card.rs is the only place its words live. Check it against the copy standard and the length limits.

  4. The Host. Teach execute in tealhand-host/src/engine.rs to run the action. The Host has already checked the signature, the nonce, the time, the fingerprint and remote control before execute runs. Add the new error codes’ sentences to refusal_text in tealhand-core/src/error.rs too, so the phone says what to do. Until the Host knows an action, it answers NotSetUp.

  5. The phone’s core. tealhand-core cleans every output that comes from a computer before a screen sees it: add the new output to sane_output in event.rs, which drops control and invisible characters and caps lengths. Then add the call to tealhand-ffi and regenerate the bridge (flutter_rust_bridge_codegen generate, then cargo fmt; see apps/mobile/README.md).

  6. The screen. Add the screen or button in apps/mobile, with every text as a key in texts.dart, and widget tests on the app’s fakes.

  7. An end-to-end test in tealhand-e2e. This is the test that proves steps 1 to 6 agree. See below.

  8. The docs. Add the action to Actions, its error texts to Error codes, and anything new to the glossary.

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:

Terminal window
scripts/ci-local.sh

It 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:

Terminal window
cargo fmt --check
cargo clippy --locked --workspace --all-targets -- -D warnings
cargo test --locked --workspace
cargo deny --locked check
cargo audit --file Cargo.lock
bash scripts/geiger-check.sh

cargo 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:

Terminal window
cargo ndk -t arm64-v8a clippy --locked -p tealhand-ffi --all-targets -- -D warnings
cargo ndk -t arm64-v8a build --locked -p tealhand-ffi --release
cargo build --locked -p tealhand-ffi
cd apps/mobile
flutter pub get --enforce-lockfile
flutter analyze --fatal-infos
flutter test

The 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.

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.rs covers connecting, status, cards, rules, the notice, the roster push and forgetting. tests/files.rs covers 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 Pc and a Phone, 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).
Terminal window
cargo test --locked -p tealhand-e2e