Skip to main content

TN protocol: attested logging with JWE + btn ciphers

Project description

tn-proto

Signed, encrypted, append-only logging — one entry per event.

Install (from TestPyPI)

The packages publish to TestPyPI. Install them from TestPyPI while letting pip pull their ordinary dependencies from regular PyPI:

pip install -i https://test.pypi.org/simple/ --extra-index-url https://pypi.org/simple/ tn-proto tn-core tn-btn
  • -i https://test.pypi.org/simple/ — get the TN packages from TestPyPI.
  • --extra-index-url https://pypi.org/simple/ — get everything else (PyYAML, cryptography, httpx, …) from PyPI.

tn-proto is the SDK; tn-core and tn-btn are the Rust-backed wheels that provide native acceleration and the default btn cipher. Pure-Python install (tn-proto alone) works too, on the jwe cipher.

Getting started

The first run mints a ceremony at ./.tn/default/ — nothing to configure.

import tn

tn.init()
tn.info("order.created", order_id="A100", amount=4999)
tn.warning("order.flagged", order_id="A100", reason="hold")

for entry in tn.read():
    print(entry.level, entry.event_type, entry.fields)
info order.created {'amount': 4999, 'order_id': 'A100'}
warning order.flagged {'order_id': 'A100', 'reason': 'hold'}

A tn CLI ships with the package. Set TN_NO_STDOUT=1 to silence the stdout echo. There's no explicit flush — the SDK drains on interpreter exit.

The verbs

verb what it does
tn.init(...) resolve or create a ceremony, bind the runtime
tn.info / .warning / .error / .debug one signed, encrypted entry per call at that level
tn.log(event_type, *, level="", **fields) severity-less entry; pass level= for a custom level
tn.read(...) iterate decoded entries
tn.watch(...) tail the log live (async iterator)
tn.absorb / tn.export install or produce a .tnpkg bundle

tn.log vs the level verbs

tn.info("user.signed_in", user="alice")   # level="info", threshold-aware
tn.log("user.signed_in", user="alice")    # level="",     always emits
tn.log("scan.tick", level="trace",
       phase="discovery")                  # level="trace" (custom), always emits

Reach for tn.log when you need a level outside debug / info / warning / error, or when you want an event that survives the level-threshold filter regardless of what tn.set_level is set to.

Reading: all runs, this run, admin

tn.read() defaults to every entry on disk (all_runs=True). A fresh python hello.py reading an existing .tn/ log will surface yesterday's events. To restrict to entries written by this process's runtime, pass all_runs=False:

import tn
tn.init()

# All entries on the main log (default).
for e in tn.read():
    print(e.level, e.event_type, e.fields)
# info order.created {'amount': 4999, 'order_id': 'A100'}
# warning order.flagged {'order_id': 'A100', 'reason': 'hold'}

# Restrict to entries emitted by this process run.
for e in tn.read(all_runs=False):
    print(e.level, e.event_type, e.fields)
# (empty in a fresh process; nothing was emitted yet this run)

# Admin log (ceremony lifecycle), addressed explicitly by name.
for e in tn.read(log="admin"):
    print(e.level, e.event_type)
# info tn.ceremony.init
# info tn.group.added
# info tn.group.added

The all_runs=True default was chosen in 0.4.1a3 so that tn read (CLI) and tn.read() (Python) match the operator expectation of "show me what's in this log file." Set all_runs=False explicitly to scope a read to the current run.

The default surface (tn.read() / tn.watch() with no log=) is the main user log only. Admin envelopes (tn.*) live in a separate log; address them by name when you want them. log= also accepts a template (see Templated paths below).

Vault and dashboard

Fresh ceremonies are minted linked to the cyaxios vault by default. Inspect:

import tn
tn.init()
cfg = tn.current_config()
print(cfg.mode)              # linked
print(cfg.linked_vault)      # https://vault.tn-proto.org
print(cfg.linked_project_id) # None  (empty until claimed)
print(cfg.is_linked())       # True

On-disk yaml ceremony block:

ceremony:
  id: local_696951e4
  mode: linked
  linked_vault: https://vault.tn-proto.org
  linked_project_id: ''
  sync_logs: false
  cipher: btn
  sign: true

Nothing reaches the network until an explicit vault verb fires. A linked-by-default ceremony is safe even on a machine that never sees the vault.

Project identity and named streams

When you call tn.init('billing') against an empty project, you'll see TWO directories appear on disk:

.tn/
├── default/          ← project identity anchor (auto-created)
│   ├── keys/
│   ├── tn.yaml
│   └── ...
└── billing/          ← the stream you asked for
    ├── logs/billing.ndjson
    ├── tn.yaml       ← carries `extends: ../default/tn.yaml`
    └── ...

This is by design (DX review #14). Named ceremonies are streams layered on a shared project identity:

  • The project's device DID + signing key live exactly once, at .tn/default/keys/. All entries from any stream attest under that same publisher.
  • Each named stream's tn.yaml carries extends: ../default/tn.yaml. The loader pulls identity, keystore, groups, and recipients from default at config-load time. Streams own their logs, admin log, chain state, and per-stream handlers — not identity.
  • Editing default's groups affects all streams in that project. No drift, no manual sync.

If you want a truly standalone ceremony at an arbitrary path (no .tn/default/, no shared identity), use the explicit yaml_path= form:

tn.init(yaml_path="./my-custom-yaml.yaml", cipher="btn")

That mints a fresh self-contained ceremony at the given path with its own DID + keystore + no extends reference.

Profiles — pick the trade-off, not the knobs

tn.init(profile=...) selects a curated bundle of evidence and performance trade-offs. Profiles are SDK-fixed (not user-composable); pick the closest match and the runtime applies the bundle. The catalog has five entries today:

Profile encrypts signs chains flush default_sink use for
transaction yes yes yes fsync file_rotating grants, revokes, payments, agent actions, security events
audit yes yes yes buffered file_rotating normal business events; same evidence as transaction, weaker durability
secure_log yes yes no buffered file_rotating sensitive app logs where signing matters more than sequence
telemetry yes no no async stdout high-volume traces / metrics; near-zero overhead vs logging.Logger
stdout yes no no async stdout dev / notebook scratchpad — print() shape with encryption still on

Encryption is always on — that's the protocol floor. The other four axes (signs, chains, flush, default_sink) vary by profile.

Examples — one per profile

import tn

# transaction — the default. Grants, payments, anything you'd want
# to audit later.
tn.init(profile="transaction")        # same as tn.init()
tn.info("payment.completed", user="alice", amount=4999, currency="USD")

# audit — buffered writes for higher throughput on normal events.
tn.init(profile="audit")
tn.info("order.viewed", order_id="A100", viewer="bob")

# secure_log — signed but no chain. Use when chain coordination
# costs more than per-row sequence is worth.
tn.init(profile="secure_log")
tn.info("session.opened", session_id="s12", actor="alice")

# telemetry — unsigned, async, stdout-only. Near-zero overhead.
# No on-disk log file: `tn.read()` for this ceremony returns empty.
tn.init(profile="telemetry")
tn.info("page.viewed", path="/dashboard", latency_ms=87)

# stdout — dev-friendly default. Same evidence shape as telemetry
# but framed as "the logger you reach for in a notebook."
tn.init(profile="stdout")
tn.info("debug.note", message="trying something out", attempt=1)

What's wired in 0.4.2a2

Axis Wired? Where
signs yes ceremony.sign in yaml; Rust runtime emits empty signature when False
default_sink yes Default-ceremony and per-stream yamls drop file.rotating for stdout-sink profiles
chains no — runtime gap Rust runtime always chains. secure_log / telemetry / stdout still emit prev_hash + sequence. Tracked in DX_FIXES.md profile-audit section.
flush no — runtime gap Handler dicts don't carry flush policy. The catalog's per-profile flush bit (fsync / buffered / async) is documentation-only today.

The two gaps need Rust runtime work in crypto/tn-core/; both are captured as xfailed tests in tests/test_profile_full_matrix.py so they flip green automatically once the runtime grows the matching switches.

To engage the vault:

tn wallet status        # is this machine linked, to what
tn wallet link          # claim a project on the vault
tn wallet restore       # pull every ceremony from the vault

To skip auto-link on init in interactive contexts (notebooks auto-fire by default, scripts only when asked):

tn.init(link=False)            # never auto-fire (yaml stays linked-shaped)
TN_NO_LINK=1                   # env-level hard kill switch

For a truly air-gapped ceremony, edit the yaml after init and set ceremony.mode: local (and remove linked_vault).

The dashboard at vault.tn-proto.org lets a publisher invite a reader by email, watch absorb status, and trigger rotations. Everything the dashboard does is backed by the same .tnpkg format that tn.export and tn.absorb produce locally.

Running TN in containers and CI

On your laptop, tn init writes an identity to your home directory and you're done. In a container or a CI job there's no home directory, and you can't bake an identity into the image: anyone with the image would have your keys. The way TN gets identity onto a fresh container is the API key bootstrap. Set it up once per project and forget about it.

The mental model: TN_API_KEY is a single string you paste into your platform's secret store. When your container boots, it trades that string with the vault for its keystore, then runs normally.

1. Mint an API key

Sign in at https://vault.tn-proto.org/account, open the project this deploy will write to, click API keysGenerate persistent key, and copy the result. The key looks like:

tn_apikey_<43-char block>_<22-char block>

That single string is everything your container needs. It's reusable across deploys; you don't regenerate it per build.

2. Hand it to your platform as a secret

Put the key in your platform's secret store. The container reads it from the environment variable TN_API_KEY:

Platform Where the secret goes
Cloudflare Workers / Containers wrangler secret put TN_API_KEY, or Secrets Store
GitHub Actions Repository or org secret TN_API_KEY
AWS Secrets Manager or SSM Parameter, exposed as env var
GCP Secret Manager, exposed as env var
Azure Key Vault, exposed as env var
Plain Docker -e TN_API_KEY=... (never commit it)

3. Deploy

Your container boots, sees TN_API_KEY, fetches its keystore from the vault, and starts serving. First boot has one extra round trip to the vault; subsequent restarts reuse the local cache and skip it.

That's the entire setup. No keys in the image, no identity files in the repo, no startup script.

Disk wins over env

If a project keystore already exists at <keystore>/local.private, TN uses it and ignores TN_API_KEY entirely. This means:

  • On your laptop, after tn init, your local keystore takes precedence even if TN_API_KEY is set in your shell.
  • In a container with persistent storage (Cloudflare Containers' R2-backed volume, a mounted EBS, etc.), the keystore survives across cold starts; only the very first boot does the bootstrap round trip.
  • To force a re-bootstrap, delete the keystore directory and restart.

The on-disk paths are the same on every OS; the only thing that varies is where the per-user identity file lives:

OS Per-user identity file
macOS / Linux ~/.local/share/tn/identity.json
Windows %APPDATA%\tn\identity.json

Override with TN_IDENTITY_DIR if you want a non-default location. The per-project keystore is always under .tn/<ceremony>/keys/ relative to wherever your project lives.

Rotating the key

Generate a new key in the vault UI, update the secret in your platform, redeploy. Running containers keep working until they restart; new ones come up with the new key.

Groups

A group is a cipher domain. Every event you write lands in one or more groups based on field routing in the yaml. Each group has its own publisher state and its own reader list. Readers of group payments can decrypt payments events, and only those.

Fresh ceremonies start with two groups:

  • default: everything you emit without explicit routing
  • tn.agents: reserved, used by the protocol for agent-policy events

Add more in tn.yaml:

groups:
  default:
    cipher: btn
  payments:
    cipher: btn
    fields: [order_id, amount, card_last4]
  audits:
    cipher: jwe
    fields: [reviewer_did, decision]

A single tn.info(...) call can fan an event into N groups, each encrypted under that group's readers only.

Readers

A reader of a group can decrypt that group's entries. As the publisher you grant read access by minting a kit for the reader's DID. The reader installs the kit (or a .tnpkg bundle that contains it) and from then on tn.read returns decoded entries on their machine.

Python:

import tn
tn.init()
result = tn.admin.add_recipient(
    group="default",
    recipient_did="did:key:z6MkAliceExamplePublicKey",
    out_path="./alice.btn.mykit",
)
print(result.leaf_index, result.kit_path)
# 1 alice.btn.mykit

The call writes a .btn.mykit file and emits a tn.recipient.added admin event.

CLI, one-shot mint plus .tnpkg bundle ready to hand off:

$ tn add_recipient default alice
[tn add_recipient] wrote /your/cwd/alice.tnpkg
[tn add_recipient]   group:     default
[tn add_recipient]   recipient: did:key:zLabel-alice

The CLI form synthesises a did:key:zLabel-<name> for friendly labels, mints the kit, and wraps it as a .tnpkg in one step.

Revoke a reader when you need to:

tn.admin.revoke_recipient(group="default", leaf_index=1)

For btn groups the broadcast tree handles thousands of readers with sub-millisecond encrypt. Revocation is selective: the revoked kit stops decrypting, every other reader keeps working without rekeying.

Bundles

A .tnpkg is a signed zip containing a manifest and body files.

Producer:

tn.export("alice.tnpkg",
          kind="kit_bundle",
          to_did="did:key:z6MkAlice...",
          seal_for_recipient=True)

seal_for_recipient=True wraps the body under a per-export key that only the named DID can unwrap, so a vault or CDN can host the bundle without being able to read its contents.

Reader:

import tn

# tn.absorb needs an existing ceremony to install into. Run tn.init()
# first; absorb merges the kit material into your current ceremony.
tn.init()
receipt = tn.absorb("./alice.tnpkg")
print(receipt.kind, receipt.accepted_count, receipt.deduped_count)
# kit_bundle 1 0

Rotation

tn rotate writes a new generation of group keys and emits one per-recipient .tnpkg artifact for surviving readers. The CLI runs unattended:

$ tn rotate
[tn rotate] rotated 1 group(s); emitted 1 .tnpkg artifact(s) into
            /your/cwd/rotated_20260513T224809Z
             default: epoch=1
             -> did_key_zLabel-alice.tnpkg

Distribute the per-recipient files (vault push, CI artifact, email, your choice). Each reader runs tn absorb on theirs.

Templated paths

Both the admin log address (admin_log_location in the yaml) and the main log address (logs.path plus the matching handlers[].path entry) accept six tokens: {event_type}, {event_class}, {date}, {yaml_dir}, {ceremony_id}, {did}.

logs:
  path: "./logs/{event_class}/{date}.ndjson"
handlers:
  - kind: file.rotating
    name: main
    path: "./logs/{event_class}/{date}.ndjson"   # same template

Each emit lands in its rendered file:

$ ls .tn/default/logs/
audit/    order/    payment/
$ ls .tn/default/logs/order/
2026-05-13.ndjson

Read it back as a single stream:

for entry in tn.read(log="./logs/{event_class}/{date}.ndjson",
                     all_runs=True):
    print(entry.event_type)
# audit.review
# order.created
# order.shipped
# payment.captured

Unknown tokens fail at tn.init() time, not at first emit.

Scoped lifecycle

For test code or anything that wants a TN context bounded by a code block instead of the global init:

with tn.session() as s:
    s.log("order.created", order_id="A100")
    s.log("order.shipped", order_id="A100")
# block exit: session's ephemeral ceremony is torn down,
# the global runtime (if any) is restored

tn.session() creates an isolated tmpdir ceremony for the duration of the block. The handle's verbs (s.info, s.read, etc.) match the module-level ones. Used for tests and short-lived processes that don't want to leave a ./.tn/ on disk.

For the long-running-process case, just tn.init() once at startup; the global runtime stays for the life of the interpreter and drains on exit.

Cross-language

binding install use case
Python pip install tn-proto reference, CLI, services
TypeScript / Node tn-proto (npm) Node services, build tools
Browser (WASM) bundled via tn-proto-web in-browser verify and decrypt

Every binding reads byte-identical envelopes. A row written by Python can be decoded in the browser, and vice versa. Cross-language parity tests run on every PR.

CLI

tn --help lists every verb. Common ones:

tn init ./project              # provision identity + ceremony
tn add_recipient default alice # mint a kit for 'alice', wrap as .tnpkg
tn rotate                      # rotate every non-internal group;
                               # emit one per-recipient .tnpkg artifact
tn absorb ./alice.tnpkg        # install someone's bundle
tn read                        # decoded entries to stdout
tn read --all-runs             # include entries from prior process runs
python -m tn.watch ./tn.yaml   # follow the log live (jsonl to stdout)
tn streams                     # list ceremonies under .tn/
tn validate                    # validate the project's config tree
tn wallet restore              # pull every ceremony from the vault

Non-interactive by default. Safe in CI and containers. A TTY enables prompts where they are useful.

Troubleshooting

symptom likely cause
KeystoreConflict: state for group X has diverged on disk Another process mutated the same ceremony's state. Re-run the admin verb; it picks up the fresh state and re-applies.
tn.watch shows no tn.* events By design. Pass log="admin".
tn.read() shows entries from previous runs The default is all_runs=True (every entry on disk). Pass all_runs=False to restrict to this process's run.
tn: no ceremony found when running tn.absorb tn.absorb merges INTO an existing ceremony. Run tn.init() first.
Wheel install fails on an exotic platform Source build needs Rust >= 1.85 (rustup install stable).

Where to next

License: Apache-2.0

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distributions

No source distribution files available for this release.See tutorial on generating distribution archives.

Built Distributions

If you're not sure about the file name format, learn more about wheel file names.

tn_proto-0.6.0a2-cp39-abi3-win_amd64.whl (2.0 MB view details)

Uploaded CPython 3.9+Windows x86-64

tn_proto-0.6.0a2-cp39-abi3-manylinux_2_34_x86_64.whl (2.1 MB view details)

Uploaded CPython 3.9+manylinux: glibc 2.34+ x86-64

tn_proto-0.6.0a2-cp39-abi3-macosx_11_0_arm64.whl (1.9 MB view details)

Uploaded CPython 3.9+macOS 11.0+ ARM64

File details

Details for the file tn_proto-0.6.0a2-cp39-abi3-win_amd64.whl.

File metadata

  • Download URL: tn_proto-0.6.0a2-cp39-abi3-win_amd64.whl
  • Upload date:
  • Size: 2.0 MB
  • Tags: CPython 3.9+, Windows x86-64
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for tn_proto-0.6.0a2-cp39-abi3-win_amd64.whl
Algorithm Hash digest
SHA256 cfd26d8c14af5aff7ae153c82576ffdac00aa062609c670ef32ffb2841e9b769
MD5 4b7b00d2525a8e3c3b101503ca92114d
BLAKE2b-256 4c77dfa4acb065b9cfcd124c6237013b8b98c57263ba9e4d5a259ddf135552e4

See more details on using hashes here.

File details

Details for the file tn_proto-0.6.0a2-cp39-abi3-manylinux_2_34_x86_64.whl.

File metadata

File hashes

Hashes for tn_proto-0.6.0a2-cp39-abi3-manylinux_2_34_x86_64.whl
Algorithm Hash digest
SHA256 ccc2fccfb1939219ceda71a15c8b38a33ed4659b51f0f65cecca3ba6dc3b5034
MD5 bb59a43cd5d54d9680c9e14b920168c5
BLAKE2b-256 15cbeef7b0d96c80932182742c6fe2cedd7f26ccf37324179f9d220e9e85998b

See more details on using hashes here.

File details

Details for the file tn_proto-0.6.0a2-cp39-abi3-macosx_11_0_arm64.whl.

File metadata

File hashes

Hashes for tn_proto-0.6.0a2-cp39-abi3-macosx_11_0_arm64.whl
Algorithm Hash digest
SHA256 620b1c4a9363655d55310a6e56175206ea91d0838b982c5657478050b61d45fa
MD5 b0935b2c9d375bd9942e16fb718ed784
BLAKE2b-256 546a8e0cf86da9306521cd2278f99b3bbcedc760368e456eb7e095670e036421

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page