Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Introduction

Detritus is a lightweight crash and log receiver for small Rust projects.

Version 0.1.0 has two ingest paths:

  • OTLP/gRPC logs through opentelemetry.proto.collector.logs.v1.LogsService.Export
  • HTTP/2 multipart crash uploads at /v1/crashes

The workspace is split into three crates:

  • detritus-protocol provides the shared wire types, crash schema, and curated OTLP facade.
  • detritus-client provides the tracing layer, panic hook, and offline crash shipper for Rust applications.
  • detritus-server provides the detritusd receiver binary and an embeddable server API.

The documentation in this book focuses on the stable operational and architectural contract that is already implemented in the repository:

  • the one-process, one-port receiver model
  • filesystem-backed persistence
  • per-project bearer-token auth
  • payload-level schema validation
  • zstd-compressed crash uploads with byte-exact server-side storage
  • single-instance and multi-instance NixOS deployment

API reference for the published crates lives on docs.rs:

Installation

Detritus is a Rust workspace with a Nix flake. You can build it either with Cargo directly or through the flake outputs.

Prerequisites

  • Rust 1.88 or newer for direct Cargo builds
  • protoc available on PATH for direct Cargo builds of the protocol crate
  • Nix with flakes enabled if you want the reproducible flake environment

Build With Nix

From the repository root:

nix develop
nix build .#detritus
nix build .#docs

nix develop gives you the workspace toolchain plus mdbook.

Build With Cargo

From the repository root:

cargo build --workspace --all-features
cargo test --workspace --all-features

To run the receiver binary from source:

cargo run -p detritus-server -- --help

Crate Installation

Operators can install the published receiver binary with Cargo:

cargo install detritus-server

Library users depend on the published crates in the usual Cargo way:

[dependencies]
detritus-client = "0.1.0"
detritus-protocol = "0.1.0"

Quick Start

This quick start brings up a local receiver and shows the minimum configuration the server expects.

1. Prepare a token file

detritusd requires a TOML file containing Argon2 PHC hashes, not raw bearer tokens. The file shape is:

[[token]]
id = "local-dev"
secret = "$argon2id$v=19$m=19456,t=2,p=1$..."
project = "detritus"
source_prefix = "detritus/"

[rate_limit]
logs_per_minute = 1000
logs_burst = 200
crashes_per_minute = 30
crashes_burst = 5

Every ingest request except /healthz and /metrics must present the matching bearer token with Authorization: Bearer <token>.

2. Start the receiver

From the repository root:

cargo run -p detritus-server -- \
  --bind 127.0.0.1:4317 \
  --data-dir ./detritus-data \
  --tokens-config ./tokens.toml

The default listener is 127.0.0.1:4317. That single listener serves both OTLP/gRPC logs and multipart crash uploads.

3. Verify the local listener

curl -i http://127.0.0.1:4317/healthz

The handler returns 204 No Content on success.

4. Exercise crash ingest

curl -X POST \
  -H "Authorization: Bearer <token>" \
  http://127.0.0.1:4317/v1/crashes \
  -F 'metadata={"schema_version":1,"source":{"project":"detritus","platform":"linux","version":"smoke","install_id":"00000000-0000-4000-8000-000000000001"},"timestamp":"2026-05-19T00:00:00Z","kind":"PanicTarball","build":{"git_sha":"smoke","profile":"release","target_triple":"x86_64-unknown-linux-gnu"},"panic_text":"smoke","context":{},"attachments":[]}' \
  -F dump=@/tmp/fake.bin

On success the server responds with 201 Created and a JSON body containing the crash blob hash and whether the upload deduplicated against an existing blob.

5. Point a Rust application at the receiver

The client SDK installs as a tracing layer plus an optional panic hook.

The install_layer example enables zstd-compressed OTLP log exports to the local Detritus 0.2 receiver with .compression(detritus::CompressionEncoding::Zstd).

See the crate examples for runnable code:

  • cargo run --example install_layer -p detritus-client
  • cargo run --example crash_envelope -p detritus-protocol
  • cargo run --example embed_server -p detritus-server

Architecture

This document captures the implemented v1 architecture decisions.

Naming

The project name is detritus.

Workspace crates are:

  • detritus-protocol
  • detritus-client
  • detritus-server

The server binary is detritusd. The client-facing Rust API is centered on detritus::Layer and detritus::install_panic_hook.

Logs

Logs use OTLP/gRPC. The server implements opentelemetry.proto.collector.logs.v1.LogsService.Export.

This keeps the log path aligned with standard OpenTelemetry clients while avoiding a custom log wire protocol.

Crash Uploads

Crash dumps use an HTTP/2 multipart POST to /v1/crashes.

Two multipart parts are required:

  • metadata, a JSON CrashMetadata document
  • dump, the binary crash artifact

Optional attachments use multipart names of the form attach:<key>.

One Process, One Port

Detritus runs as one process on one port. The receiver uses axum and tonic::routes() so gRPC and HTTP requests share one axum::Router.

That single listener is the deployment contract used by the local quick start, the NixOS module, and reverse-proxy configurations.

Persistence

Persistence is filesystem-only in v1.

  • Logs are appended as NDJSON files per (project, source-id, day).
  • Crash dumps and attachments are stored in a content-addressed SHA-256 blob store.
  • Per-source crash indexes are stored as JSON alongside the source namespace.

The server does not require a database.

Auth

Auth uses bearer tokens in the Authorization header.

Each token is scoped to:

  • one project
  • one canonical source_prefix

Server-side token files store Argon2 PHC hashes instead of raw bearer tokens.

Compression

Crash uploads use a two-layer compression model.

Wire-level gzip is handled by the HTTP/gRPC stack. Payload-level zstd is handled by detritus-client before upload. The server stores the uploaded bytes exactly as received and records per-part content_encoding in the on-disk crash index when a part was encoded.

See Compression Model for the concrete rules.

Multi-Tenancy

The top-level namespace is project. The client identity inside a project is install_id.

That shape is reused across:

  • token authorization
  • storage layout
  • schema validation
  • multi-instance deployment planning

Storage Layout

detritusd --data-dir <path> stores all ingest artifacts under that root:

<data-dir>/
  logs/<project>/<source-id>/YYYY-MM-DD.ndjson
  crashes/by-hash/<2-hex-prefix>/<sha256>.bin
  crashes/by-source/<project>/<source-id>/<timestamp>-<sha256>.json
  tmp/

<source-id> is the SourceId.install_id UUID. Project, platform, version, and install ID are still preserved inside crash metadata and the required OTLP resource attributes.

Logs

Logs are append-only NDJSON.

Each accepted OTLP LogRecord produces one JSON line in the current UTC day file for its source.

Crash Blobs

Crash dumps and attachments are content-addressed by SHA-256.

The server writes uploaded bytes into <data-dir>/tmp/, fsyncs the temporary file, links it into crashes/by-hash/<2-hex-prefix>/<sha256>.bin, and then removes the temporary file.

If the hash already exists, the upload is treated as a deduplicated blob and the temporary file is removed.

Crash Indexes

Each accepted crash upload also writes a per-source JSON index under:

crashes/by-source/<project>/<source-id>/

The index contains:

  • the submitted CrashMetadata
  • the dump blob pointer
  • attachment blob pointers
  • content_encoding for any part uploaded with an encoding such as zstd

Compression Model

Detritus uses two distinct compression layers for crash ingest.

Layer 1: Transport Compression

Transport compression is handled by the HTTP and gRPC stack.

  • OTLP/gRPC accepts uncompressed, gzip, and zstd messages. The client sends uncompressed requests by default; select an algorithm with LayerBuilder::compression. Response compression is negotiated with Tonic.
  • Crash uploads accept whole-request gzip and zstd compression, independently of multipart part encodings. HTTP responses negotiate gzip or zstd using Accept-Encoding.
  • The 150 MiB HTTP request-body limit is applied after decompression.

For example, add .compression(detritus::CompressionEncoding::Zstd) to a log layer builder when using an upgraded receiver. A failed compressed export is spooled as the original protobuf request, so replay remains independent of the transport encoding selected by the next process.

The log exporter reuses a Tonic channel across requests and applies the flush timeout to both connection establishment and each RPC, including the gRPC deadline sent to the receiver. HTTPS uses system trust roots; a detritus::ClientTlsConfig supplied to LayerBuilder::tls_config supports private CAs, domain overrides, and client identities for mutual TLS. TLS termination remains at the reverse proxy in the standard receiver deployment.

These integrations use the documented Tonic compression API, Tonic TLS configuration, and tower-http decompression.

Layer 2: Payload Compression

detritus-client compresses crash payload bytes before upload.

  • Dump bytes are compressed with zstd by default.
  • The default zstd level is 19.
  • Text-like attachments are also compressed by default.
  • The metadata JSON part is never compressed.

The client sets Content-Encoding: zstd on the individual multipart parts that were compressed.

Attachment Heuristic

The client compresses these content types:

  • text/*
  • application/json
  • application/x-ndjson
  • application/yaml
  • application/x-yaml
  • application/xml

The client leaves these content types uncompressed:

  • application/zstd
  • application/gzip
  • application/x-gzip
  • application/zip
  • application/x-tar
  • application/octet-stream
  • image/*
  • video/*
  • audio/*

Hashing And Deduplication

The crash blob hash covers the bytes sent over the wire, which means the SHA-256 is computed over the compressed bytes.

That preserves dedup semantics: the same source dump compressed the same way lands on the same content-addressed blob.

Server Contract

The server does not decompress crash blobs during ingest.

It stores the uploaded bytes exactly as received and records part-level content_encoding in the crash index so later tooling can decide whether and how to decode the blob.

Schema Validation

Detritus supports optional per-project JSON Schema validation for crash metadata and OTLP log attributes.

Schema Kinds

The implemented schema kinds are:

  • crash_metadata
  • log_attributes

Each schema registration is keyed by (project, kind).

Token File Extension

Schema registrations live in the same TOML file as bearer tokens:

[[schema]]
project = "acme"
kind = "crash_metadata"
path = "schemas/crash.schema.json"

[[schema]]
project = "acme"
kind = "log_attributes"
path = "schemas/log.schema.json"

Schema paths are resolved relative to the token file’s parent directory, not relative to the current working directory.

Validation Behavior

At startup, the server loads and compiles every declared schema into a process-wide registry.

Request handling then applies the registry like this:

  • crash uploads validate the metadata JSON against the project’s crash_metadata schema
  • OTLP log exports validate each ResourceLogs payload against the project’s log_attributes schema

If no schema is registered for a given (project, kind) pair, the request is accepted.

Shared references and format policy

Use local resource documents to share definitions across tenant schemas:

[schema_validation]
validate_formats = true
ignore_unknown_formats = false

[[schema_validation.resources]]
uri = "urn:detritus:common"
path = "schemas/common.json"

A registered schema can reference a definition with {"$ref": "urn:detritus:common#/$defs/build"}. Resource paths are resolved relative to the token file, just like tenant schema paths. All resources are prepared at startup; no network or implicit filesystem retrieval is enabled. Resource URIs must be unique.

If validate_formats is omitted, format validation follows the schema draft’s default. Setting it to true enforces formats such as email, uuid, and date-time, including Draft 2020-12 schemas where formats are normally annotations. ignore_unknown_formats = false makes unknown format names a startup error when format validation is enabled. Both options preserve the library defaults when omitted.

Embedded callers can use SchemaRegistry::load_with_options, SchemaOptions, and SchemaResourceEntry for the same behavior. The implementation uses jsonschema’s prepared reference registry and validation options.

Failure Modes

Validation failures are endpoint-specific:

  • crash validation failures are rejected on the HTTP path
  • log validation failures are rejected on the OTLP/gRPC path

The server also increments validation-failure metrics so operators can distinguish malformed client payloads from transport or auth failures.

Diagnostics include the instance JSON Pointer and the schema keyword path. The validator’s masked error display hides rejected payload values, allowing clients to locate a validation failure without echoing their data in the error.

Deployment Note

The NixOS module exposes schemaDir. When set, it is mounted read-only into the service and is intended to hold the JSON Schema files referenced by tokensConfig.

Operations Overview

This page documents the server-side operational contract for detritusd.

Token Configuration

Start the server with:

detritusd --tokens-config /etc/detritus/tokens.toml

The token file uses Argon2 PHC hashes. The literal bearer token is distributed out-of-band and is not stored in the config.

Generate a new hash by supplying one line on stdin:

detritusd hash-token < /run/secrets/receiver-token

The command prints a randomly salted Argon2id PHC string and needs no server configuration. It strips the line ending while preserving spaces in the token, rejects empty input, and does not print the secret. Hashes created with older Argon2 versions continue to authenticate.

[[token]]
id = "regicide-prod"
secret = "$argon2id$v=19$m=19456,t=2,p=1$..."
project = "regicide"
source_prefix = "regicide/"

[[token]]
id = "rs-modde-prod"
secret = "$argon2id$v=19$m=19456,t=2,p=1$..."
project = "rs-modde"
source_prefix = "rs-modde/"

[rate_limit]
logs_per_minute = 1000
logs_burst = 200
crashes_per_minute = 30
crashes_burst = 5

Every request except /healthz and /metrics requires Authorization: Bearer <token>.

Request correlation and shutdown

Every HTTP and gRPC response includes x-request-id. The server preserves a supplied ID or generates a UUID, and attaches it to the request’s tracing span. Authorization headers are marked sensitive before request tracing.

Ctrl-C and Unix SIGTERM both initiate graceful shutdown, stop the retention worker, and drain log writers before the process exits. detritusd --version prints the installed crate version.

Request Limits

Default limits are per (token, source-id):

logs:    1000 batches/minute, burst 200
crashes:   30 dumps/minute, burst 5

The server returns gRPC ResourceExhausted for logs and HTTP 429 for crashes when a bucket is empty.

Retention

Defaults:

logs TTL:    14 days
crashes TTL: 90 days
janitor:      1 hour interval

Relevant CLI flags:

detritusd \
  --logs-ttl-days 14 \
  --crashes-ttl-days 90 \
  --janitor-interval-secs 3600

The janitor removes expired log files, expired crash source indexes, and then any content-addressed blobs no longer referenced by an index.

Health And Metrics

/healthz is unauthenticated and returns 204 No Content.

/metrics is also unauthenticated and is intended for loopback scraping. It emits OpenMetrics-style text with counters and histograms for request status, ingest volume, dedup hits, writer queue depth, janitor activity, and validation failures.

Ingress Topologies

For the self-hosted reverse-proxy path, see Caddy Reverse Proxy.

For Cloudflare’s hosted edge, body-size limits, and TLS tradeoffs, see Cloudflare Ingress.

NixOS Module

The flake exports a NixOS module for running detritusd in either a single-instance shorthand mode or an explicit multi-instance mode.

Flake Outputs

The repository exports:

packages.<system>.detritus
packages.<system>.docs
packages.<system>.site
nixosModules.default
nixosConfigurations.detritus-test-vm
nixosConfigurations.detritus-multi-test-vm

Single-Instance Shorthand

The existing shorthand interface is:

services.detritus = {
  enable = true;
  bind = "127.0.0.1:4317";
  tokensConfig = config.age.secrets.detritus-tokens.path;
  logsTtlDays = 14;
  crashesTtlDays = 90;
};

This produces a single detritus.service unit and keeps the historic detritus:detritus runtime identity.

Multi-Instance Mode

Explicit instances use services.detritus.instances:

services.detritus.instances = {
  acme = {
    bind = "0.0.0.0:4317";
    dataDir = "/var/lib/detritus-acme";
    tokensConfig = "/run/secrets/detritus-acme.toml";
    user = "detritus-acme";
    group = "detritus-acme";
    openFirewall = true;
  };

  beta = {
    bind = "0.0.0.0:4318";
    dataDir = "/var/lib/detritus-beta";
    tokensConfig = "/run/secrets/detritus-beta.toml";
  };
};

Each explicit instance produces its own:

  • detritus-<name>.service
  • system user and group
  • token preflight script
  • firewall opening when openFirewall = true

Important Guards

The module enforces several safety checks at evaluation time:

  • you cannot mix the top-level shorthand with services.detritus.instances
  • dataDir must be listed in knownDataDirs
  • explicit instances may not share the same dataDir
  • explicit instances may not bind the same TCP port

These guards exist to prevent silent data orphaning and port collisions during upgrades.

Schema Files

Each instance optionally accepts schemaDir.

When set, the module:

  • grants the service read-only access to that path
  • expects schema file references in tokensConfig to resolve inside that directory tree

Runtime Contract

The systemd unit refuses to start if tokensConfig is not mode 0400 and owned by the configured service user and group.

By default the service stores persistent data in /var/lib/detritus for the shorthand mode and /var/lib/detritus-<name> for explicit instances.

Caddy Reverse Proxy

This page documents the self-hosted ingress path for Detritus on canix hosts.

Topology

Caddy terminates public TLS and forwards cleartext loopback traffic to detritusd.

client
  |
  | HTTPS
  v
Caddy :443
  |
  | plain HTTP / h2c on loopback
  v
detritusd 127.0.0.1:4317

The same upstream listener carries both OTLP/gRPC logs and multipart crash uploads.

Receiver Bind

The correct bind address behind Caddy is:

detritusd --bind 127.0.0.1:4317 --tokens-config /etc/detritus/tokens.toml

Do not bind 0.0.0.0:4317 just to make the proxy work.

canix Route Shape

The canix Caddy preset uses canix.presets.services.caddy.mkReverseProxyRoute. A Detritus host configuration looks like this:

{
  config,
  inputs,
  lib,
  ...
}: let
  hostname = "detritus.example.com";
  bindAddr = "127.0.0.1";
  bindPort = 4317;
in {
  imports = [
    inputs.detritus.nixosModules.default
  ];

  services.detritus = {
    enable = true;
    bind = "${bindAddr}:${toString bindPort}";
    tokensConfig = config.age.secrets.detritus-tokens.path;
  };

  canix.presets.services.caddy.routes = [
    (lib.recursiveUpdate
      (config.canix.presets.services.caddy.mkReverseProxyRoute {
        inherit hostname;
        port = bindPort;
        host = bindAddr;
      })
      {
        handle = [
          {
            handler = "reverse_proxy";
            transport = {
              protocol = "http";
              versions = ["h2c" "2"];
            };
            upstreams = [
              { dial = "${bindAddr}:${toString bindPort}"; }
            ];
          }
        ];
      })
  ];
}

The important part is the h2c upstream transport, which lets tonic keep serving gRPC over cleartext loopback.

Body Size And Timeouts

The server accepts up to 100 * 1024 * 1024 bytes for each dump or attachment part and applies a request-wide 150 MiB body limit.

If you add a Caddy-side request-body limit, keep it aligned with the server request limit:

request_body {
  max_size 150MiB
}

Client Configuration

Clients should point at the public HTTPS origin, not the loopback listener.

detritus-client calls detritus::install_default_crypto_provider() before it builds its HTTPS crash shipper. Applications that construct their own reqwest clients earlier in process startup need to call that function themselves before the first TLS handshake.

Health Check

The receiver exposes GET /healthz, not /health.

Through Caddy:

curl -i https://detritus.example.com/healthz

Cloudflare Ingress

This page explains when Cloudflare’s hosted edge is a practical ingress layer for Detritus and when you should keep TLS termination on your own infrastructure.

For the self-hosted alternative, see Caddy Reverse Proxy.

Recommendation

Cloudflare’s free proxy is practical when all of these are true:

  • crash uploads stay comfortably below the 100 MB free-tier body limit after zstd compression
  • unary OTLP/gRPC is sufficient
  • TLS termination at Cloudflare’s edge is acceptable for your data

Prefer direct ingress, Tailscale, WireGuard, or another self-hosted reverse proxy when any of these are false.

Relevant Cloudflare Limits

Body Size

Free and Pro plans cap request bodies at 100 MB.

For Detritus, that means an oversized crash upload is rejected with HTTP 413 before it reaches detritusd.

gRPC

Detritus uses unary OTLP/gRPC log export, which Cloudflare’s HTTP/2 transit supports.

If your deployment later needs bidirectional or server-streaming gRPC behavior, re-evaluate the proxy decision against Cloudflare’s current product behavior.

TLS

Cloudflare terminates TLS at the edge. The origin leg should run in Full or Full (strict) mode with a valid origin certificate.

If decrypting crash or log payloads at Cloudflare is unacceptable, do not use Cloudflare for Detritus ingress.

Request Timeouts

Long uploads over slow links can time out at the Cloudflare edge. Payload-level zstd compression helps by shrinking crash bodies before upload.

Alternative Topologies

  • direct public ingress with your own reverse proxy and certificate
  • a Tailscale or WireGuard tailnet
  • Cloudflare Tunnel, if the network path matters more than the edge data-visibility tradeoff

Re-Evaluate When

Revisit the decision if:

  • real-world uploads hit HTTP 413
  • typical crash sizes grow toward the plan limit
  • your compliance requirements change
  • your ingest surface expands beyond unary gRPC and HTTP multipart uploads

Offline Shipping

Detritus panic hooks write crash reports to a local spool before any network work happens. A later process can flush those pending reports without reconstructing the original SDK configuration by using the stored-config shipper.

Use this path for next-launch cleanup, recovery tools, or a small helper process that only knows the spool directory and has access to a bearer token:

#![allow(unused)]
fn main() {
use detritus::ship_pending_crashes_using_stored_config;
use secrecy::SecretString;

async fn run() -> Result<(), detritus::ShipError> {
let shipped = ship_pending_crashes_using_stored_config(
    "/var/lib/my-app/detritus",
    SecretString::from("receiver-token"),
)
.await?;
let _ = shipped;
Ok(())
}
}

Each pending entry carries an sdk-config.json written by the panic hook. The stored-config shipper reads that file for the upload endpoint and sent-entry retention, so entries in the same spool can be posted to different receivers. The bearer token is still supplied by the caller and is never stored in the spool. Keeping the token outside the crash directory lets applications persist recoverable routing information without leaving long-lived credentials on disk.

Entries missing sdk-config.json, or entries whose stored endpoint is not a valid URL, fail with ShipError::MissingStoredConfig. That is intentional: mixed spools produced by older clients should be handled explicitly rather than silently skipped.

Reusable clients and transport policy

For repeated scans, keep a CrashShipper to reuse HTTP connections:

#![allow(unused)]
fn main() {
use detritus::{CrashShipper, ShipConfig};
use secrecy::SecretString;

async fn run() -> Result<(), detritus::ShipError> {
let shipper = CrashShipper::new(SecretString::from("receiver-token"))?
    .with_config(ShipConfig::default().with_dump_compression_level(3));
let shipped = shipper.ship_using_stored_config("/var/lib/my-app/detritus").await?;
let _ = shipped;
Ok(())
}
}

The default connect timeout is 10 seconds, the read timeout is 30 seconds, and the total request timeout is 60 seconds. Failed or timed-out uploads remain in pending/. Default HTTP-level retries are disabled: the spool controls replay, and a single upload attempt should not create multiple crash indexes.

For a private CA, mutual TLS, a proxy, or different timeouts, configure a Reqwest client and pass it to CrashShipper::with_client(client, token). Its retry and timeout settings replace the defaults. Call detritus::install_default_crypto_provider before constructing a custom HTTPS client.

The convenience shipping functions use the same default transport and share one connection pool throughout each scan. Compression and the on-disk spool format are identical for both APIs.

Releasing

This page consolidates the stable release and publishing procedure for the workspace.

Published Crates

The workspace publishes three crates:

  • detritus-protocol
  • detritus-server
  • detritus-client

All three stay version-synchronized during 0.x.

Release Order

The release workflow publishes in topological dependency order:

  1. detritus-protocol
  2. detritus-server
  3. detritus-client

After publishing detritus-protocol and detritus-server, the release workflow waits for the new version to become visible on the crates.io sparse index before continuing.

CI Contract

On every push to trunk and every pull request, CI runs:

  • cargo fmt --all --check
  • cargo clippy --workspace --all-features --all-targets -- -D warnings
  • cargo check --workspace --all-features
  • cargo test --workspace --all-features
  • cargo doc --workspace --no-deps --all-features
  • an MSRV check on Rust 1.88
  • cargo audit --deny warnings
  • cargo deny --all-features check

CI dry-runs only detritus-protocol:

cargo publish --dry-run -p detritus-protocol --allow-dirty

The dependent crates cannot be dry-run published on arbitrary push and PR commits because their versioned sibling dependencies are resolved against the live crates.io index.

docs.rs Contract

Each published crate carries:

[package.metadata.docs.rs]
all-features = true
rustdoc-args = ["--cfg", "docsrs"]

That keeps docs.rs builds aligned with the feature-complete public API.

Manual Release Procedure

  1. Bump every published crate version together.
  2. Bump the version = "X.Y.Z" qualifiers on internal path dependencies.
  3. Move the CHANGELOG.md unreleased entries under a dated release heading.
  4. Commit the release bump.
  5. Push the commit to trunk and wait for CI to go green.
  6. Create and push the vX.Y.Z tag.
  7. Wait for the release workflow to publish the crates in order.
  8. Verify the published versions on crates.io and then verify the docs.rs builds.

First Release Checklist

Before the first publish of a crate name:

  • verify the crate name is free on crates.io
  • publish with the tag-driven workflow
  • add any intended co-owners after the first successful publish

Supply-Chain Checks

The repository keeps deny.toml at the root and enforces both cargo-audit and cargo-deny in CI. Treat advisory or licensing failures as release blockers.