Skip to main content

taut

One tiny declarative contract → native types, a deterministic wire codec, and service stubs across Python, TypeScript, Rust, C++, Swift, Go, Kotlin, JS, and Java — verified byte-for-byte by a shared golden corpus.

taut is a cross-language data + service protocol mechanism: protobuf's essential value (a single governed schema, generated types, a portable wire format, an evolution gate) with roughly 90% less of its weight (no plugin toolchain, no .proto grammar, no required runtime, a wire you can read in an afternoon). The schema is authored as a small, restricted Python DSL — or any .ir.json — and everything else is projected from it.

Status: alpha. The model, codec, generators, corpus, and evolution gate are implemented across nine languages; APIs may still move.

pip install taut-proto      # distribution name (PyPI)
import taut                 # import name
tautc gen api.taut.py -o gen/   # CLI codegen

The distribution is named taut-proto (PyPI rejects the bare taut); the import package and CLI are taut / tautc — the Pillow→PIL pattern. Pure Python, no runtime dependencies.


taut vs proto3

A schema language earns its keep by removing ambiguity between services in different languages. proto3 (with gRPC) does that but drags in a code-gen plugin architecture, a bespoke grammar, per-language runtimes, an HTTP/2 RPC stack, and a wire format few can read by hand. taut keeps the parts that matter and drops the rest:

Dimension proto3 / gRPC taut
Schema source .proto DSL + protoc + a plugin per language a few lines of typed Python, or .ir.json
Toolchain protoc compiler + language plugins one pip install; tautc for compiled targets
Wire format varint tag-length-value; not guaranteed canonical across implementations (maps are explicitly unordered) frozen deterministic CBOR (RFC 8949 §4.2) — byte-identical everywhere, corpus-pinned
Codegen required? yes for most languages (or descriptor-based reflection) no for Python/TS (IR-driven runtime codec); generated for the compiled targets (Rust/C++/Swift/Go/Kotlin/JS/Java)
Runtime dependency a protobuf runtime per language none (stdlib; hand-rolled codec per target)
Field presence scalars had none; optional presence re-added in 3.15 optional → null; presence is clean (fields always emitted)
required removed in proto3 kept as a governance assertion, enforced by the evolution gate and by decode (an absent field is MissingKey)
Forward-compat (unknown fields) dropped in 3.0, restored in 3.5 — and binary only (the JSON mapping drops them) opt-in unknown-field preservation (raw tags re-emitted in canonical order)
Services / RPC service/rpc, but the transport is gRPC: HTTP/2 + codegen + runtime (name, in, out, shape); transport-agnostic (reference: JSON envelope + CBOR payload)
Streaming gRPC client/server/bidi streams first-class streaming delivery engines/profiles: atom / log / stream / swmr / snapshot_delta / crdt
Schema evolution field-number rules + reserved; breaking-change detection is external (buf, protolock) a built-in structural breaking-change gate, runnable in CI
Extensions proto2 had them; proto3 dropped them (use Any) declared, typed side-channels at a reserved tag band
Read the whole spec large surface the IR fits on a screen

Because the IR is data — not a Turing-complete program — taut can diff two versions of a contract and tell you mechanically what broke.

What taut deliberately doesn't have (yet): oneof types, well-known types (Timestamp/Any/Duration), a canonical JSON profile (deferred), descriptor/reflection services, and — above all — proto's ecosystem maturity, scale hardening, and gRPC's battle-tested multi-language RPC runtime. taut is a focused mechanism, not a drop-in protobuf replacement for every use case. If you need proto's breadth, use proto; taut is the lighter contract when you want ~10% of the load for the parts that actually bite.

The contract

A schema is enums + messages + services, authored declaratively:

from taut.ir.dsl import INT, STR, BYTES, Enum, F, Msg, Params, Ref, List, method, service, schema

SCHEMA = schema(
    service("Razel",
        method("build", role="in", params=Params(target=STR), out=Ref.BuildResult),
        method("build.subscribe", role="out", shape="atom", out=Ref.BuildResult)),

    BuildStatus=Enum(cached=0, built=1, failed=2),

    OutputArtifact=Msg(
        path=F(1, STR),
        digest=F(2, BYTES),
        next_id=3),

    BuildResult=Msg(
        target=F(1, STR),
        status=F(2, Ref.BuildStatus),
        outputs=F(3, List(Ref.OutputArtifact)),
        message=F(4, STR, optional=True),
        next_id=5),
)
  • Fields carry an explicit integer tag, a type, and flags (optional, transient, CRDT merge). Types are a closed set: scalars (int/str/bytes/ bool), enum/message refs, List, and Map (a key-sorted, deterministic keyed collection — K scalar, V scalar/enum/message).
  • Keyword-named enums, messages, fields, Ref.Name references, and Params(...) method params are preferred. The explicit string forms remain available for names that are not Python identifiers, Python keywords, or Msg(...) control names such as reserved and next_id.
  • reserved + next_id are first-class, validated message features (not comments) — retired tags/names can never be reused, and next_id is checked.
  • Methods are the minimal contract (name, in, out, shape). shape is the sole discriminator: unary (request→response, the default) is just the degenerate member of the validated canonical shape registry, alongside value (attributed LWW state) and streaming shapes atom (latest-wins state), log (append-only), stream (live), swmr (snapshot+delta), snapshot_delta, and crdt. out binds a type per the shape's delivery slots. Registry recognition is separate from adapter runtime capability. There is no separate "kind" axis to disagree — illegal states are unrepresentable. (See Reference §5–6.)

A validator rejects incoherent IR (dangling refs, duplicate tags, out-slots that don't match a shape, …), so downstream mechanism can be derived without review.

The wire

A hand-rolled, frozen deterministic CBOR subset: definite-length, shortest- form integers, ascending integer map keys. The same value encodes to the same bytes in every language — pinned by a golden conformance corpus (value → exact hex). Python and TypeScript run a fully IR-driven codec (instantiate a client or server from JSON alone, zero codegen); Rust, C++, Swift, Go, Kotlin, JS, and Java get generated native types with encoders/decoders (compiled targets need types ahead of time). The C++ corpus is a wall of static_asserts — compiling is the test.

Decode is fail-closed in all nine: every decode entry point returns a value or the language's DecodeError, whatever the bytes, and never panics or overflows its stack. It is bounded by the schema's max_depth (default 32, ceiling 128) and max_encoded_len (no default) options, declared in the DSL as option.max_depth(16). The codec contract has the error's tags and the bounds.

Opinionated about the wire, not the API

Small-and-tight vs. bloated is only half the pitch. The other half: taut pins exactly what cross-language interop requires — the deterministic wire, the (name, in, out, shape) contract, and the evolution gate — and nothing more. Those must be fixed, so they are.

How that contract surfaces in your code is your call. The IR is the governed artifact (a tiny JSON); projecting it into types, clients, and servers is a choice, not a mandate:

  • The bundled tautc generators are reference projections. Use them, swap them, or write your own against the exported .ir.json — pydantic models, plain dataclasses, a transactional/ORM wrapper, different client ergonomics, whatever fits your app.
  • Or generate nothing: Python and TypeScript drive the IR straight through the runtime codec.

Where protoc hands you its message classes and gRPC its stubs, taut hands you the bytes and the schema and gets out of the way.

tautc — the reference codegen CLI

tautc gen IR --out DIR [--lang python,typescript,rust,cpp] [--service A,B] [--api-only]

Loads a .taut.py or .ir.json, validates it, and emits per language: api (native types + encoders/decoders), plus client/server stubs per service. --api-only emits just the struct defs + codec — the drop-in for a build script on a compiled target. --with-runtime additionally emits the vendored CBOR runtime for compiled targets (rust → cbor.rs, cpp → taut/cbor.hpp), so the generated output compiles standalone — no hand-vendoring. Example: razel (a Rust build daemon) authors ir/razel.taut.py and generates its Rust wire layer with tautc gen ir/razel.taut.py -o razel/gen --lang rust --api-only.

For Rust schemas composed with a dependency's exported schema, --rust-external-types types.json reuses the dependency's native types. The JSON object maps schema names to Rust paths, for example {"Packet": "owner::api::Packet", "Kind": "owner::api::Kind"}. Keep the complete dependency definitions in the composed IR; mapped types become pub use declarations instead of duplicate structs or enums. Unmapped types are generated normally. Other language targets still generate their types.

The consumer must share the owner's CBOR runtime (for example, pub use owner::cbor; at the crate root), use compatible codec options, and pin the owner schema/package alongside its generator. Do not install a second generated cbor module in that consumer: Rust treats those runtime types as distinct even when their source is identical. This option provides type references, not package discovery or dependency-version resolution. The generator refuses a nonempty external-type map together with --with-runtime so this shared-runtime requirement cannot be bypassed accidentally.

These are the reference emitters; they read the exported .ir.json, and so can a generator you write — tautc is a convenience, not a requirement.

Conformance — tautc corpus IR -o DIR [--lang rust] [--check] derives a golden corpus from the IR (deterministic coverage values, one per message, encoded by the canonical codec → golden.json) plus a per-language byte-parity harness (Rust vectors.rs today). A target conforms iff its codec reproduces those bytes; --check is the CI drift gate. No hand-authored vectors.

CBOR ↔ JSON — tautc json IR -m MESSAGE converts the deterministic-CBOR wire to/from JSON via the IR (stdin/stdout or -i/-o files; --from-json reverses; --indent pretty-prints). proto3-style conventions (int64→string, bytes→base64, enum→name); cbor → json → cbor is byte-identical. Handy for debugging and JSON-native gateways:

some-producer | tautc json api.taut.py -m BuildResult            # CBOR -> JSON
tautc json api.taut.py -m BuildResult --from-json < x.json       # JSON -> CBOR

Evolution

  • Breaking-change gate — structural diff of two IR versions classifies each change breaking / compatible (remove field, retag, change a method's shape → breaking; add a field/method → compatible). Run it in CI.
  • Forward-compatibility — unknown-field preservation: a decoder keeps unrecognized tags as raw CBOR and re-emits them in canonical order, so an old reader relaying a new message loses nothing. Default-on in the Python/TS runtime codec; opt-in for the seven generated targets via tautc gen --forward-compat (a residual field, such as Rust's wire_residual), without which a generated decoder drops unknown fields. The parity gate runs every generated target both ways, and the corpus pins the round trip: a v1 struct re-encodes a v2-only field byte-for-byte — even when the unknown tag interleaves between known ones.
  • Extensions — declared, typed side-channels at a reserved tag band (BAND_START = 2^20); infrastructure reads/writes them on the wire without the app schema knowing.

Layout

src/taut/        the builder (pure Python)
  ir/            model, DSL, validator, export/load, breaking-change gate, shapes
  wire/          deterministic CBOR + IR-driven codec
  gen/           generators: Rust, C++, and the per-language scaffold
  cli.py         the `tautc` command
ir/              authored IR modules — the only governed artifact
                 (griplab.taut.py, razel.taut.py)
corpus/          generated golden vectors (the oracle)
docs/            Overview, GettingStarted, Reference, Server + a runnable example
dev-docs/        design notes + the decisions log (Taut*.md)
.github/         the PyPI publish workflow (Trusted Publishing on release)

Reference target implementations and the full cross-language interop matrix are validated against this repo's corpus. TypeScript's reusable runtime now lives in src/taut/gen/runtime/typescript/; other worked interop slices are disposable generated outputs, not durable source.

Docs

  • Overview — the mental model and the shape catalog.
  • Getting Started — author → validate → encode → generate.
  • Reference — every DSL helper, the shapes, the validator rules.
  • Building a Server — handlers, shape engines, the WS loop.
  • Per-language APIs and the codec contract — using the generated code, and the one decode contract, error and bounds all nine codecs meet.
  • examples/tasks — a complete runnable API with generated code for all nine languages.

Develop

python run_tests.py        # regenerate the corpus, run the suite

Design notes and the decisions log live in dev-docs/.

License

MIT

Metadata

Release files for taut-proto 0.10.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for taut-proto 0.10.0
File Size Uploaded
taut_proto-0.10.0.tar.gz 843.8 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for taut-proto 0.10.0
File Interpreter ABI Platform
taut_proto-0.10.0-py3-none-any.whl Python 3 none any Details

Total release size: 1.1 MB

Release files / taut_proto-0.10.0.tar.gz

Download URL taut_proto-0.10.0.tar.gz
Size 843.8 kB
Tags Source
SHA-256 checksum
How to use checksums
04985a68f9bbf528bc06ec0e1cb4506c059af099e65bdb8ddcd5b4a25e2b95f9
BLAKE2b-256 checksum
How to use checksums
76273697860c397cc117669ff88e3b0517360d29d52c28070a998eaa1ef919cc
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 30, 2026.

Transparency log

Release files / taut_proto-0.10.0-py3-none-any.whl

Download URL taut_proto-0.10.0-py3-none-any.whl
Size 244.7 kB
Tags Python 3
SHA-256 checksum
How to use checksums
9d839a8c2eea2ea3a006713b48a187699b7876e82e572f2a472b7969320dc663
BLAKE2b-256 checksum
How to use checksums
047bd6614e6fe4970fd8f5ac8e91a992ac239ba2721d19eb29a3029e294be16b
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 30, 2026.

Transparency log

Release history Release notifications | RSS feed

This release

0.10.0 This release

2 release files

0.9.1

2 release files

0.8.1

2 release files

0.8.0

2 release files

0.7.0

2 release files

0.6.0

2 release files

0.5.0

2 release files

0.4.0

2 release files

0.1.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page