From 0abb4fdbf19c360333a3cb87fcc77caadf110f53 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Thu, 17 Sep 2026 18:28:14 +0530 Subject: [PATCH 01/10] Build the typed Jev System One client --- .agents/skills/typesafe-ai/LICENSE | 21 + .agents/skills/typesafe-ai/SKILL.md | 149 +++ .env.example | 5 +- .github/workflows/ci.yml | 39 +- .github/workflows/release.yml | 591 ----------- .gitmodules | 4 - AGENTS.md | 353 +------ CONTRIBUTING.md | 10 +- Cargo.lock | 930 +++++++++++++----- Cargo.toml | 46 +- MODULE.md | 31 - README.md | 191 +--- ROADMAP.md | 27 +- crates/template-bus/Cargo.toml | 24 - crates/template-bus/README.md | 100 -- crates/template-bus/src/greeting/mod.rs | 17 - crates/template-bus/src/greeting/test.rs | 65 -- crates/template-bus/src/greeting/types.rs | 54 - crates/template-bus/src/lib.rs | 74 -- crates/template-bus/src/names/mod.rs | 33 - crates/template-bus/src/names/test.rs | 28 - crates/template-bus/src/version/mod.rs | 46 - crates/template-bus/src/version/test.rs | 34 - .../examples/verify_github_release.rs | 93 -- crates/template/examples/verify_module.rs | 74 -- crates/template/src/error/test.rs | 17 - crates/template/src/greeting/mod.rs | 38 - crates/template/src/greeting/test.rs | 28 - crates/template/src/tinybus_module/README.md | 20 - crates/template/src/tinybus_module/mod.rs | 43 - crates/template/src/tinybus_module/test.rs | 64 -- crates/tinyjevclient/Cargo.toml | 29 + crates/tinyjevclient/examples/basic.rs | 27 + crates/tinyjevclient/src/README.md | 8 + crates/tinyjevclient/src/client/README.md | 6 + crates/tinyjevclient/src/client/mod.rs | 199 ++++ crates/tinyjevclient/src/client/test.rs | 164 +++ crates/tinyjevclient/src/client/types.rs | 115 +++ crates/tinyjevclient/src/error/README.md | 5 + crates/tinyjevclient/src/error/mod.rs | 86 ++ crates/tinyjevclient/src/error/test.rs | 33 + crates/tinyjevclient/src/lib.rs | 47 + crates/tinyjevclient/src/request/README.md | 5 + crates/tinyjevclient/src/request/mod.rs | 119 +++ crates/tinyjevclient/src/request/test.rs | 120 +++ crates/tinyjevclient/src/request/types.rs | 78 ++ crates/tinyjevclient/src/response/README.md | 5 + crates/tinyjevclient/src/response/mod.rs | 135 +++ crates/tinyjevclient/src/response/test.rs | 109 ++ crates/tinyjevclient/src/response/types.rs | 71 ++ crates/tinyjevclient/tests/public_api.rs | 26 + deny.toml | 2 +- docs/README.md | 7 +- docs/plans/README.md | 2 +- docs/plans/example-retry-policy.md | 71 -- docs/plans/system-one-client.md | 7 + docs/plans/tinybus-module-release.md | 11 - docs/specs/README.md | 2 +- docs/specs/example-retry-policy.md | 63 -- docs/specs/system-one-client.md | 23 + docs/specs/tinybus-module-release.md | 33 - skills-lock.json | 11 + 62 files changed, 2387 insertions(+), 2481 deletions(-) create mode 100644 .agents/skills/typesafe-ai/LICENSE create mode 100644 .agents/skills/typesafe-ai/SKILL.md delete mode 100644 .github/workflows/release.yml delete mode 100644 .gitmodules delete mode 100644 MODULE.md delete mode 100644 crates/template-bus/Cargo.toml delete mode 100644 crates/template-bus/README.md delete mode 100644 crates/template-bus/src/greeting/mod.rs delete mode 100644 crates/template-bus/src/greeting/test.rs delete mode 100644 crates/template-bus/src/greeting/types.rs delete mode 100644 crates/template-bus/src/lib.rs delete mode 100644 crates/template-bus/src/names/mod.rs delete mode 100644 crates/template-bus/src/names/test.rs delete mode 100644 crates/template-bus/src/version/mod.rs delete mode 100644 crates/template-bus/src/version/test.rs delete mode 100644 crates/template/examples/verify_github_release.rs delete mode 100644 crates/template/examples/verify_module.rs delete mode 100644 crates/template/src/error/test.rs delete mode 100644 crates/template/src/greeting/mod.rs delete mode 100644 crates/template/src/greeting/test.rs delete mode 100644 crates/template/src/tinybus_module/README.md delete mode 100644 crates/template/src/tinybus_module/mod.rs delete mode 100644 crates/template/src/tinybus_module/test.rs create mode 100644 crates/tinyjevclient/Cargo.toml create mode 100644 crates/tinyjevclient/examples/basic.rs create mode 100644 crates/tinyjevclient/src/README.md create mode 100644 crates/tinyjevclient/src/client/README.md create mode 100644 crates/tinyjevclient/src/client/mod.rs create mode 100644 crates/tinyjevclient/src/client/test.rs create mode 100644 crates/tinyjevclient/src/client/types.rs create mode 100644 crates/tinyjevclient/src/error/README.md create mode 100644 crates/tinyjevclient/src/error/mod.rs create mode 100644 crates/tinyjevclient/src/error/test.rs create mode 100644 crates/tinyjevclient/src/lib.rs create mode 100644 crates/tinyjevclient/src/request/README.md create mode 100644 crates/tinyjevclient/src/request/mod.rs create mode 100644 crates/tinyjevclient/src/request/test.rs create mode 100644 crates/tinyjevclient/src/request/types.rs create mode 100644 crates/tinyjevclient/src/response/README.md create mode 100644 crates/tinyjevclient/src/response/mod.rs create mode 100644 crates/tinyjevclient/src/response/test.rs create mode 100644 crates/tinyjevclient/src/response/types.rs create mode 100644 crates/tinyjevclient/tests/public_api.rs delete mode 100644 docs/plans/example-retry-policy.md create mode 100644 docs/plans/system-one-client.md delete mode 100644 docs/plans/tinybus-module-release.md delete mode 100644 docs/specs/example-retry-policy.md create mode 100644 docs/specs/system-one-client.md delete mode 100644 docs/specs/tinybus-module-release.md create mode 100644 skills-lock.json diff --git a/.agents/skills/typesafe-ai/LICENSE b/.agents/skills/typesafe-ai/LICENSE new file mode 100644 index 0000000..8c73b41 --- /dev/null +++ b/.agents/skills/typesafe-ai/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 TypeSafe AI + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/.agents/skills/typesafe-ai/SKILL.md b/.agents/skills/typesafe-ai/SKILL.md new file mode 100644 index 0000000..0109513 --- /dev/null +++ b/.agents/skills/typesafe-ai/SKILL.md @@ -0,0 +1,149 @@ +--- +name: typesafe-ai +license: MIT +description: > + Build AI-powered software with TypeSafe: small units of AI intelligence you + can use like programming primitives. Its System One models, including Jev, + turn natural language and application state into typed judgments and + probabilities that code can combine. Use when a feature needs programmable + common sense, when brainstorming what AI could make possible in an app, or + when an LLM prompt-and-parse step could become a structured decision. + Applications include routing, ranking, extraction, verification, and + interactive experiences; these are starting points, not the limits. + Read live docs and cookbooks to find useful patterns and discover new combinations. +--- + +# Build with TypeSafe + +TypeSafe makes units of AI intelligence usable like programming primitives: small +judgments you can compose into larger capabilities. Its **System One models** return +fast, focused judgments that software can consume directly. **Jev** is TypeSafe's +flagship and first System One model. It understands natural language and returns +typed answers and probabilities rather +than generating text or reasoning explanations. Code owns the workflow; the model +supplies programmable common sense where ordinary code needs semantic understanding. + +## Read the live docs + +**The live TypeSafe docs are the source of truth. Read them as part of the task.** +This skill gives direction; the docs carry current concepts, prompting guidance, +API contracts, SDK usage, models, limits, and worked examples. + +- Start with the [documentation index](https://docs.typesafe.ai/llms.txt) to discover + relevant pages and cookbooks. Use targeted reads rather than loading the entire site. +- Mintlify serves Markdown by appending `.md` to a page path, for example + [how to build with TypeSafe](https://docs.typesafe.ai/concepts/how-to-build-with-system-one.md). + Follow links from the index; convert extensionless documentation page links to + `.md` when useful. Resolve relative links against `https://docs.typesafe.ai`. +- Before writing an integration, read the current API or chosen SDK page and the + question guidance relevant to the design. For a new workflow, also inspect the + closest cookbook: it often shows a better decomposition than a generic classifier. +- If the index is unavailable, use the direct links below or the site's navigation. + If Markdown fetching fails, try the normal page. If live access is unavailable, + use available local docs or installed SDK types, state that limitation, and avoid + inventing version-dependent details. + +| Task | Start here; follow the relevant details | +| --- | --- | +| Understand the programming model | [System One](https://docs.typesafe.ai/concepts/system-one.md), [building guide](https://docs.typesafe.ai/concepts/how-to-build-with-system-one.md) | +| Explore what to build | [Use-case map](https://docs.typesafe.ai/concepts/use-case-map.md), then relevant cookbooks from the index | +| Prepare inputs and questions | [State](https://docs.typesafe.ai/concepts/state.md), [primitives](https://docs.typesafe.ai/primitives.md), then the chosen primitive's page | +| Decide how to handle uncertainty | [Confidence](https://docs.typesafe.ai/confidence.md) | +| Write API code | [HTTP API](https://docs.typesafe.ai/api.md), [Python SDK](https://docs.typesafe.ai/sdk/python.md), or [JavaScript SDK](https://docs.typesafe.ai/sdk/javascript.md) | +| Update an older integration | [Migration guide](https://docs.typesafe.ai/migrating-to-v1.md) and the installed SDK's current reference | + +## Find the useful shape + +Start from the behavior the user wants: what will the application show, select, +change, or hand off? Work backward to the judgments it needs. Keep known rules, +calculations, exact lookups, and execution in code. Preserve the user's chosen stack +and scope; add TypeSafe where semantic understanding helps. + +When brainstorming or choosing an architecture, consider more than classification. +The patterns below are starting points: combine primitives around the user's goal, +including ideas that do not fit an established recipe. + +- **Route and fill known arguments.** A request can select a handler and its typed + parameters. Ask useful branch-specific questions up front and consume only the + relevant answers. Explore [function calling](https://docs.typesafe.ai/cookbooks/function_calling.md) + and [speculative fan-out](https://docs.typesafe.ai/patterns/fan-out.md). +- **Select instead of generate.** Find candidate values or source spans in code, + use a judgment to select the intended one, then copy or normalize it. Code can + also assemble source text into a formatted document or reading guide. Explore + [value extraction](https://docs.typesafe.ai/cookbooks/pre_parsed_value_extraction_cookbook.md) + and [structure recovery](https://docs.typesafe.ai/cookbooks/autoformat.md). +- **Find and judge evidence.** Retrieve candidates, compare their relevance to a + query, and select useful context. Explore [reranking](https://docs.typesafe.ai/cookbooks/rerank_typesafe.md) + and [hierarchical classification](https://docs.typesafe.ai/cookbooks/hierarchical_classification.md). +- **Turn judgments into reusable data.** Score dimensions once, then let code or + user controls change weights, thresholds, rankings, and views. With labeled + outcomes, those signals can become classical ML features. Explore + [composite scoring](https://docs.typesafe.ai/patterns/composite-scoring.md) and + [feature discovery](https://docs.typesafe.ai/cookbooks/autoresearch_feature_discovery.md). +- **Verify and escalate.** Check specific claims or fields against their evidence; + send uncertain or failing cases to a person or reasoning model. Explore + [citation checks](https://docs.typesafe.ai/cookbooks/citation_check.md) and + [extraction cascades](https://docs.typesafe.ai/cookbooks/sde_cascade.md). +- **Respond to changing state.** Code can retain goals and observations while fresh + judgments guide the next bounded step. Keep inferred state distinct from observed + facts, and check freshness before applying a result to a changed situation. + +For open-ended requests, offer the few directions that best serve the user's goal +and recommend a starting point. For a concrete request, choose the relevant pattern +and build; a brainstorm is not a mandatory detour. + +## Design the judgments + +Choose by what the answer means, then read the relevant primitive page: + +| Need | Primitive | Important distinction | +| --- | --- | --- | +| One of a defined set | [Choice](https://docs.typesafe.ai/primitives/choice.md) | Picks one option; its distribution compares competing options | +| Whether a condition holds | [Noul](https://docs.typesafe.ai/primitives/noul.md) | Probability of yes; no separate confidence; use one per label when several may apply | +| Degree along a described dimension | [Score](https://docs.typesafe.ai/primitives/score.md) | Probability-weighted position on ordered levels; use comparable per-item Scores for graded ranking | + +Give each question enough relevant **state** to answer: source text, identities, +relationships, policies, and current facts. Prefer named JSON fields when context +has several parts. Put the judgment in **instructions** and define its possible +answers in **criteria**. Question IDs are for code and are not sent to the model; +include complete meaning in the question. Reference nested state with backticked +paths such as `ticket.messages[0].text`. + +Ask one narrow, coherent judgment per question. Split independently useful dimensions, +without destroying the relationship being judged. A bounded action selection or +contextual interpretation is valid; atomic does not mean literal fact extraction +or a one-sentence limit. Strings work for simple questions. Use structured objects +or arrays when definitions, contrasts, exclusions, or examples clarify instructions +or criteria. Score levels must describe concrete situations and stand on their own. + +Keep the needed answers available. Include a no-match outcome when nothing may fit; +use a separate presence judgment when it is independently useful. For source-value +selection, check candidate coverage: the model cannot choose an omitted value. + +## Compose and verify + +**Ask independent questions over the same state together**, including useful +speculative questions. They run in parallel and cannot see one another's answers. +State each speculative premise explicitly; code consumes the applicable answers. +A second request is warranted when an earlier answer is needed to fetch evidence, +construct new state, or determine the next options. Extra questions still use tokens; +measure actual request budgets, cost, and end-to-end latency. + +Use probabilities and confidence to guide behavior, with thresholds evaluated on +the user's data and consequences. Choice/Score confidence summarizes distribution +concentration, not overall workflow correctness or permission to act. A Noul near +0.5 means similar probability for yes and no, not medium intensity. Several +acceptable alternatives can also spread probability; low confidence need not +invalidate a harmless preference choice. Ignore uncertainty on unused branches. + +Keep policy explicit and raw judgments reusable. Weighted scores suit compensating +preferences; an “any serious violation” rule needs separate conditions. Changing a +weight or display filter need not rerun inference when evidence and question meanings +are unchanged. Typed output guarantees the interface, not truth. System One models +are trained for calibrated decisions; validate their performance in the target domain. + +Test representative cases and the resulting application behavior. For failures, +inspect the exact state, questions, candidates, answers, composition, and observed +outcome. Separate missing evidence, model errors, code errors, and service failures. +Treat cookbook thresholds and demo results as examples to evaluate, not universal +rules or permanent model limitations. Keep API credentials server-side in web apps. diff --git a/.env.example b/.env.example index e22ca63..b50c317 100644 --- a/.env.example +++ b/.env.example @@ -14,6 +14,5 @@ # Backtraces for local debugging: 1 for a short backtrace, "full" for all frames. # RUST_BACKTRACE=1 -# Example of a credential a live/network-gated test would need. Tests that -# require one must skip cleanly when it is unset. -# EXAMPLE_API_KEY=replace-me +# TypeSafe credential used only by the live example and explicitly gated runs. +TYPESAFE_API_KEY=replace-me diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ba8c2fc..ff2213b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,7 +26,6 @@ jobs: # This job executes repository code (cargo build/test); don't persist # the token in git config. persist-credentials: false - submodules: recursive - uses: dtolnay/rust-toolchain@stable with: @@ -53,35 +52,6 @@ jobs: - name: Test default features run: cargo test - # `cargo build --all-targets` only *compiles* an example. `AGENTS.md` - # promises `cargo run -p template --example basic` works, and a compiled - # example can still fail on its first line. - - name: Run the bundled example - run: cargo run -p template --example basic - - # `crates/template-bus` exists so a host can name the payload types - # without compiling the module. That promise is invisible in a diff, - # because a forbidden dependency arrives transitively through a feature - # someone enabled one crate away — so it is asserted rather than - # documented. - # - # The FORWARD form is required. `cargo tree -i -p template-bus` - # discards the `-p` scope, prints the whole-workspace inverse tree, and - # exits 0 looking clean even when this crate is the one at fault. - - name: Assert the contract crate stays transport-free - run: | - set -euo pipefail - forbidden="$(cargo tree -p template-bus -e normal,build --prefix none \ - | grep -Ei 'tinybus|tokio|reqwest|ureq|hyper|rusqlite|git2' || true)" - if [ -n "$forbidden" ]; then - echo "template-bus pulled in a dependency its manifest forbids:" >&2 - echo "$forbidden" >&2 - echo >&2 - echo "The contract is what a host compiles against. It must stay free" >&2 - echo "of transports, async runtimes, HTTP clients and native libraries." >&2 - exit 1 - fi - - name: Require 90% line coverage in every source file run: .github/scripts/check-file-coverage.sh 90 coverage.json @@ -100,7 +70,6 @@ jobs: - uses: actions/checkout@v7 with: persist-credentials: false - submodules: recursive - uses: dtolnay/rust-toolchain@stable @@ -118,17 +87,14 @@ jobs: - uses: actions/checkout@v7 with: persist-credentials: false - submodules: recursive - # `rust-version` is inherited from `[workspace.package]`, so every member - # reports the same value. Read it off the package the module ships as - # rather than off `packages[0]`, whose order cargo does not promise. + # `rust-version` is inherited from `[workspace.package]`. - name: Read rust-version from Cargo.toml id: msrv run: | set -euo pipefail msrv="$(cargo metadata --format-version 1 --no-deps \ - | jq -r '.packages[] | select(.name == "template") | .rust_version')" + | jq -r '.packages[] | select(.name == "tinyjevclient") | .rust_version')" if [[ -z "$msrv" || "$msrv" == "null" ]]; then echo "workspace.package.rust-version is not set in Cargo.toml" >&2 exit 1 @@ -151,7 +117,6 @@ jobs: - uses: actions/checkout@v7 with: persist-credentials: false - submodules: recursive - name: Check advisories, licenses, bans, and sources uses: EmbarkStudios/cargo-deny-action@v2 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml deleted file mode 100644 index 4acf379..0000000 --- a/.github/workflows/release.yml +++ /dev/null @@ -1,591 +0,0 @@ -name: Release - -on: - workflow_dispatch: - inputs: - bump: - description: Version bump to release - type: choice - required: true - options: - - patch - - minor - - major - - current - -concurrency: - group: release-${{ github.ref_name }} - cancel-in-progress: false - -permissions: - contents: write - -env: - # The workspace member that ships as the loadable module. Its package name is - # the artifact name and the library name; `crates/template-bus` rides along on - # the same inherited version and is not packaged separately. - RELEASE_PACKAGE: template - -jobs: - prepare: - name: Prepare release - if: ${{ github.ref == 'refs/heads/main' }} - runs-on: ubuntu-latest - outputs: - crate_name: ${{ steps.version.outputs.crate_name }} - next_version: ${{ steps.version.outputs.next_version }} - tag: ${{ steps.version.outputs.tag }} - steps: - - uses: actions/checkout@v7 - with: - fetch-depth: 0 - submodules: recursive - - - uses: dtolnay/rust-toolchain@stable - with: - components: rustfmt, clippy - - - uses: taiki-e/install-action@v2 - with: - tool: cargo-llvm-cov - - - uses: Swatinem/rust-cache@v2 - - - name: Check formatting - run: cargo fmt --all -- --check - - - name: Clippy - run: cargo clippy --all-targets --all-features -- -D warnings - - - name: Build - run: cargo build --all-targets --all-features - - - name: Test - run: cargo test --all-features - - - name: Require 90% line coverage in every source file - run: .github/scripts/check-file-coverage.sh 90 target/coverage.json - - - name: Build documentation - env: - RUSTDOCFLAGS: -D warnings - run: cargo doc --no-deps --all-features - - - name: Compute next version - id: version - shell: bash - run: | - set -euo pipefail - - metadata="$(cargo metadata --format-version 1 --no-deps)" - crate_name="$(jq -r --arg name "$RELEASE_PACKAGE" \ - '.packages[] | select(.name == $name) | .name' <<< "$metadata")" - current_version="$(jq -r --arg name "$RELEASE_PACKAGE" \ - '.packages[] | select(.name == $name) | .version' <<< "$metadata")" - if [[ -z "$crate_name" || "$crate_name" == "null" ]]; then - echo "Could not resolve the crate name" >&2 - exit 1 - fi - if [[ -z "$current_version" || "$current_version" == "null" ]]; then - echo "Could not resolve the current crate version" >&2 - exit 1 - fi - - IFS=. read -r major minor patch <<< "$current_version" - case "${{ inputs.bump }}" in - current) - ;; - major) - major=$((major + 1)) - minor=0 - patch=0 - ;; - minor) - minor=$((minor + 1)) - patch=0 - ;; - patch) - patch=$((patch + 1)) - ;; - *) - echo "Unsupported bump: ${{ inputs.bump }}" >&2 - exit 1 - ;; - esac - - next_version="${major}.${minor}.${patch}" - tag="v${next_version}" - git fetch --tags origin - - if git rev-parse --verify --quiet "refs/tags/${tag}"; then - if [[ "${{ inputs.bump }}" != "current" ]]; then - echo "Tag ${tag} already exists" >&2 - exit 1 - fi - tagged_version="$( - git show "${tag}:Cargo.toml" \ - | sed -n '/^\[workspace\.package\]/,/^\[/ s/^version = "\([^"]*\)"/\1/p' \ - | head -n 1 - )" - if [[ "$tagged_version" != "$current_version" ]]; then - echo "Tag ${tag} does not contain version ${current_version}" >&2 - exit 1 - fi - elif [[ "${{ inputs.bump }}" == "current" ]]; then - echo "Tag ${tag} does not exist; choose a semantic version bump" >&2 - exit 1 - fi - - { - echo "crate_name=${crate_name}" - echo "current_version=${current_version}" - echo "next_version=${next_version}" - echo "tag=${tag}" - } >> "$GITHUB_OUTPUT" - - - name: Update crate version - if: ${{ inputs.bump != 'current' }} - env: - CRATE_NAME: ${{ steps.version.outputs.crate_name }} - NEXT_VERSION: ${{ steps.version.outputs.next_version }} - run: | - set -euo pipefail - # One version for the whole workspace: every member inherits it with - # `version.workspace = true`, so this is the only edit needed. - perl -0pi -e 's/(\[workspace\.package\][\s\S]*?\nversion = ")[^"]+(")/$1$ENV{NEXT_VERSION}$2/' Cargo.toml - # `--workspace` re-resolves the local packages only, which is what a - # version bump changes. `-p --precise` cannot express "and the - # other member moved too". - cargo update --workspace - released="$(cargo metadata --format-version 1 --no-deps \ - | jq -r --arg name "$CRATE_NAME" \ - '.packages[] | select(.name == $name) | .version')" - if [[ "$released" != "$NEXT_VERSION" ]]; then - echo "version bump did not take: expected ${NEXT_VERSION}, got ${released}" >&2 - exit 1 - fi - - - name: Commit version bump and tag - if: ${{ inputs.bump != 'current' }} - env: - RELEASE_TAG: ${{ steps.version.outputs.tag }} - run: | - set -euo pipefail - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add Cargo.toml Cargo.lock - git commit -m "Release ${RELEASE_TAG}" - git tag -a "${RELEASE_TAG}" -m "Release ${RELEASE_TAG}" - git push origin "HEAD:${GITHUB_REF_NAME}" - git push origin "${RELEASE_TAG}" - - native-bundles: - name: Rust module bundle (${{ matrix.id }}) - needs: prepare - strategy: - fail-fast: false - matrix: - include: - - id: ubuntu-22.04-x86_64 - os: ubuntu-22.04 - target: x86_64-unknown-linux-gnu - - id: ubuntu-22.04-arm64 - os: ubuntu-22.04-arm - target: aarch64-unknown-linux-gnu - - id: ubuntu-24.04-x86_64 - os: ubuntu-24.04 - target: x86_64-unknown-linux-gnu - - id: ubuntu-24.04-arm64 - os: ubuntu-24.04-arm - target: aarch64-unknown-linux-gnu - - id: macos-15-x86_64 - os: macos-15-intel - target: x86_64-apple-darwin - - id: macos-15-arm64 - os: macos-15 - target: aarch64-apple-darwin - - id: macos-26-x86_64 - os: macos-26-intel - target: x86_64-apple-darwin - - id: macos-26-arm64 - os: macos-26 - target: aarch64-apple-darwin - - id: windows-2022-x86_64 - os: windows-2022 - target: x86_64-pc-windows-msvc - - id: windows-2025-x86_64 - os: windows-2025 - target: x86_64-pc-windows-msvc - - id: windows-11-arm64 - os: windows-11-arm - target: aarch64-pc-windows-msvc - runs-on: ${{ matrix.os }} - steps: - - uses: actions/checkout@v7 - with: - ref: ${{ needs.prepare.outputs.tag }} - persist-credentials: false - submodules: recursive - - - uses: dtolnay/rust-toolchain@stable - - - uses: Swatinem/rust-cache@v2 - - - name: Verify native Rust target - shell: bash - env: - EXPECTED_TARGET: ${{ matrix.target }} - run: | - set -euo pipefail - actual_target="$(rustc -vV | sed -n 's/^host: //p')" - if [[ "$actual_target" != "$EXPECTED_TARGET" ]]; then - echo "expected ${EXPECTED_TARGET}, got ${actual_target}" >&2 - exit 1 - fi - - - name: Build installable module - run: cargo build --locked --release --lib --package ${{ env.RELEASE_PACKAGE }} - - - name: Verify Unix module through TinyBus loader - if: ${{ runner.os != 'Windows' }} - shell: bash - env: - CRATE_NAME: ${{ needs.prepare.outputs.crate_name }} - run: | - set -euo pipefail - library_name="${CRATE_NAME//-/_}" - case "$RUNNER_OS" in - Linux) module="target/release/lib${library_name}.so" ;; - macOS) module="target/release/lib${library_name}.dylib" ;; - *) echo "unsupported Unix runner: ${RUNNER_OS}" >&2; exit 1 ;; - esac - cargo run --locked --package template --example verify_module -- "$module" - - - name: Verify Windows module through TinyBus loader - if: ${{ runner.os == 'Windows' }} - shell: pwsh - env: - CRATE_NAME: ${{ needs.prepare.outputs.crate_name }} - run: | - $ErrorActionPreference = 'Stop' - $libraryName = $env:CRATE_NAME.Replace('-', '_') - $module = "target/release/$libraryName.dll" - $verifyRoot = Join-Path $env:RUNNER_TEMP 'template-module-verify' - New-Item -ItemType Directory -Force $verifyRoot | Out-Null - - $identity = [System.Security.Principal.WindowsIdentity]::GetCurrent() - $security = [System.Security.AccessControl.DirectorySecurity]::new() - $security.SetOwner($identity.User) - $security.SetAccessRuleProtection($true, $false) - $rights = [System.Security.AccessControl.FileSystemRights]::FullControl - $inheritance = [System.Security.AccessControl.InheritanceFlags]'ContainerInherit, ObjectInherit' - $propagation = [System.Security.AccessControl.PropagationFlags]::None - $access = [System.Security.AccessControl.AccessControlType]::Allow - foreach ($sidValue in @( - $identity.User.Value, - 'S-1-5-18', - 'S-1-5-32-544' - )) { - $sid = [System.Security.Principal.SecurityIdentifier]::new($sidValue) - $rule = [System.Security.AccessControl.FileSystemAccessRule]::new( - $sid, - $rights, - $inheritance, - $propagation, - $access - ) - [void]$security.AddAccessRule($rule) - } - Set-Acl -LiteralPath $verifyRoot -AclObject $security - - $verifiedModule = Join-Path $verifyRoot "$libraryName.dll" - Copy-Item -LiteralPath $module -Destination $verifiedModule - cargo run --locked --package template --example verify_module -- $verifiedModule - - - name: Assemble Unix module package - if: ${{ runner.os != 'Windows' }} - id: unix_package - shell: bash - env: - BUNDLE_ID: ${{ matrix.id }} - CRATE_NAME: ${{ needs.prepare.outputs.crate_name }} - VERSION: ${{ needs.prepare.outputs.next_version }} - run: | - set -euo pipefail - - library_name="${CRATE_NAME//-/_}" - case "$RUNNER_OS" in - Linux) module="target/release/lib${library_name}.so" ;; - macOS) module="target/release/lib${library_name}.dylib" ;; - *) echo "unsupported Unix runner: ${RUNNER_OS}" >&2; exit 1 ;; - esac - if [[ ! -f "$module" ]]; then - echo "module artifact is missing: ${module}" >&2 - exit 1 - fi - - package_name="${CRATE_NAME}-${VERSION}-${BUNDLE_ID}" - package_root="dist/${package_name}" - mkdir -p "$package_root" - install -m 755 "$module" "$package_root/" - install -m 644 LICENSE MODULE.md "$package_root/" - - module_name="$(basename "$module")" - module_hash="$(shasum -a 256 "$package_root/$module_name" | awk '{print $1}')" - printf '"%s" = "%s"\n' "$module_name" "$module_hash" \ - > "$package_root/modules.toml" - - tar -C "$package_root" -czf "dist/${package_name}.tar.gz" . - echo "archive=dist/${package_name}.tar.gz" >> "$GITHUB_OUTPUT" - - - name: Assemble Windows module package - if: ${{ runner.os == 'Windows' }} - id: windows_package - shell: pwsh - env: - BUNDLE_ID: ${{ matrix.id }} - CRATE_NAME: ${{ needs.prepare.outputs.crate_name }} - VERSION: ${{ needs.prepare.outputs.next_version }} - run: | - $ErrorActionPreference = 'Stop' - - $libraryName = $env:CRATE_NAME.Replace('-', '_') - $module = "target/release/$libraryName.dll" - if (-not (Test-Path -LiteralPath $module -PathType Leaf)) { - throw "module artifact is missing: $module" - } - - $packageName = "$env:CRATE_NAME-$env:VERSION-$env:BUNDLE_ID" - $packageRoot = "dist/$packageName" - New-Item -ItemType Directory -Force $packageRoot | Out-Null - Copy-Item -LiteralPath $module, 'LICENSE', 'MODULE.md' -Destination $packageRoot - - $moduleName = Split-Path -Leaf $module - $hash = (Get-FileHash -LiteralPath "$packageRoot/$moduleName" -Algorithm SHA256).Hash.ToLowerInvariant() - $utf8 = [System.Text.UTF8Encoding]::new($false) - [System.IO.File]::WriteAllText( - "$packageRoot/modules.toml", - ('"{0}" = "{1}"' -f $moduleName, $hash) + [Environment]::NewLine, - $utf8 - ) - - $archive = "dist/$packageName.zip" - Compress-Archive -Path "$packageRoot/*" -DestinationPath $archive - "archive=$archive" >> $env:GITHUB_OUTPUT - - - name: Upload Unix package - if: ${{ runner.os != 'Windows' }} - uses: actions/upload-artifact@v7 - with: - name: ${{ needs.prepare.outputs.crate_name }}-${{ matrix.id }} - path: ${{ steps.unix_package.outputs.archive }} - if-no-files-found: error - - - name: Upload Windows package - if: ${{ runner.os == 'Windows' }} - uses: actions/upload-artifact@v7 - with: - name: ${{ needs.prepare.outputs.crate_name }}-${{ matrix.id }} - path: ${{ steps.windows_package.outputs.archive }} - if-no-files-found: error - - distro-bundles: - name: Rust module bundle (${{ matrix.id }}) - needs: prepare - strategy: - fail-fast: false - matrix: - include: - - id: fedora-43-x86_64 - os: ubuntu-24.04 - container: fedora:43 - target: x86_64-unknown-linux-gnu - family: fedora - - id: fedora-43-arm64 - os: ubuntu-24.04-arm - container: fedora:43 - target: aarch64-unknown-linux-gnu - family: fedora - - id: fedora-44-x86_64 - os: ubuntu-24.04 - container: fedora:44 - target: x86_64-unknown-linux-gnu - family: fedora - - id: fedora-44-arm64 - os: ubuntu-24.04-arm - container: fedora:44 - target: aarch64-unknown-linux-gnu - family: fedora - - id: archlinux-rolling-x86_64 - os: ubuntu-24.04 - container: archlinux:base-devel - target: x86_64-unknown-linux-gnu - family: archlinux - runs-on: ${{ matrix.os }} - container: ${{ matrix.container }} - steps: - - name: Install Fedora build tools - if: ${{ matrix.family == 'fedora' }} - run: dnf install -y gcc git gzip make perl tar - - - name: Install Arch Linux build tools - if: ${{ matrix.family == 'archlinux' }} - run: pacman -Syu --noconfirm base-devel git - - - uses: actions/checkout@v7 - with: - ref: ${{ needs.prepare.outputs.tag }} - persist-credentials: false - submodules: recursive - - - uses: dtolnay/rust-toolchain@stable - - - name: Verify native Rust target - env: - EXPECTED_TARGET: ${{ matrix.target }} - run: | - set -euo pipefail - actual_target="$(rustc -vV | sed -n 's/^host: //p')" - if [[ "$actual_target" != "$EXPECTED_TARGET" ]]; then - echo "expected ${EXPECTED_TARGET}, got ${actual_target}" >&2 - exit 1 - fi - - - name: Build installable module - run: cargo build --locked --release --lib --package ${{ env.RELEASE_PACKAGE }} - - - name: Verify module through TinyBus loader - env: - CRATE_NAME: ${{ needs.prepare.outputs.crate_name }} - run: | - set -euo pipefail - library_name="${CRATE_NAME//-/_}" - verify_root="/opt/${CRATE_NAME}-module-verify" - install -d -m 700 "$verify_root" - install -m 755 "target/release/lib${library_name}.so" "$verify_root/" - cargo run --locked --package template --example verify_module -- \ - "$verify_root/lib${library_name}.so" - - - name: Assemble distribution module package - id: package - env: - BUNDLE_ID: ${{ matrix.id }} - CRATE_NAME: ${{ needs.prepare.outputs.crate_name }} - VERSION: ${{ needs.prepare.outputs.next_version }} - run: | - set -euo pipefail - - library_name="${CRATE_NAME//-/_}" - module="target/release/lib${library_name}.so" - if [[ ! -f "$module" ]]; then - echo "module artifact is missing: ${module}" >&2 - exit 1 - fi - - package_name="${CRATE_NAME}-${VERSION}-${BUNDLE_ID}" - package_root="dist/${package_name}" - mkdir -p "$package_root" - install -m 755 "$module" "$package_root/" - install -m 644 LICENSE MODULE.md "$package_root/" - - module_name="$(basename "$module")" - module_hash="$(sha256sum "$package_root/$module_name" | awk '{print $1}')" - printf '"%s" = "%s"\n' "$module_name" "$module_hash" \ - > "$package_root/modules.toml" - - tar -C "$package_root" -czf "dist/${package_name}.tar.gz" . - echo "archive=dist/${package_name}.tar.gz" >> "$GITHUB_OUTPUT" - - - name: Upload distribution package - uses: actions/upload-artifact@v7 - with: - name: ${{ needs.prepare.outputs.crate_name }}-${{ matrix.id }} - path: ${{ steps.package.outputs.archive }} - if-no-files-found: error - - github-release: - name: Create GitHub release - needs: - - prepare - - native-bundles - - distro-bundles - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v7 - with: - ref: ${{ needs.prepare.outputs.tag }} - persist-credentials: false - submodules: recursive - - - uses: dtolnay/rust-toolchain@stable - - - uses: Swatinem/rust-cache@v2 - - - uses: actions/download-artifact@v8 - with: - pattern: '*' - path: release-assets - merge-multiple: true - - - name: Create release checksum manifest with TinyBus - shell: bash - run: | - set -euo pipefail - shopt -s nullglob - assets=(release-assets/*.tar.gz release-assets/*.zip) - if [[ ${#assets[@]} -ne 16 ]]; then - echo "expected 16 module archives, found ${#assets[@]}" >&2 - exit 1 - fi - - checksum_args=() - for asset in "${assets[@]}"; do - checksum_args+=(--path "$asset") - done - cargo run --manifest-path vendor/tinybus/Cargo.toml --locked \ - --package tinybus --all-features --bin tinybus -- \ - modules checksum "${checksum_args[@]}" \ - --output release-assets/checksum.toml - - - name: Create immutable release with module packages - env: - GH_TOKEN: ${{ github.token }} - RELEASE_TAG: ${{ needs.prepare.outputs.tag }} - REPOSITORY: ${{ github.repository }} - run: | - set -euo pipefail - if gh release view "$RELEASE_TAG" --repo "$REPOSITORY" >/dev/null 2>&1; then - echo "Release ${RELEASE_TAG} already exists; immutable assets are unchanged." - else - gh release create "$RELEASE_TAG" release-assets/* \ - --repo "$REPOSITORY" \ - --verify-tag \ - --title "$RELEASE_TAG" \ - --generate-notes - fi - - - name: Verify the published module through TinyBus - shell: bash - env: - RELEASE_TAG: ${{ needs.prepare.outputs.tag }} - REPOSITORY: ${{ github.repository }} - CRATE_NAME: ${{ needs.prepare.outputs.crate_name }} - VERSION: ${{ needs.prepare.outputs.next_version }} - run: | - set -euo pipefail - archive="${CRATE_NAME}-${VERSION}-ubuntu-24.04-x86_64.tar.gz" - release_url="https://github.com/${REPOSITORY}/releases/tag/${RELEASE_TAG}" - sha256="$( - sed -n "s/^\"${archive}\" = \"\([0-9a-f]\{64\}\)\"$/\1/p" \ - release-assets/checksum.toml - )" - if [[ -z "$sha256" ]]; then - echo "checksum missing for ${archive}" >&2 - exit 1 - fi - - cargo run --manifest-path vendor/tinybus/Cargo.toml --locked \ - --package tinybus --all-features --example github_module_host -- \ - "$release_url" "$archive" "$sha256" - cargo run --locked --package template --example verify_github_release -- \ - "$release_url" "$archive" "$sha256" diff --git a/.gitmodules b/.gitmodules deleted file mode 100644 index da09a74..0000000 --- a/.gitmodules +++ /dev/null @@ -1,4 +0,0 @@ -[submodule "vendor/tinybus"] - path = vendor/tinybus - url = https://github.com/tinyhumansai/tinybus - branch = main diff --git a/AGENTS.md b/AGENTS.md index ee8fdfc..36a46e1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,126 +1,36 @@ # Repository Guidelines -This file is the single source of truth for how humans and coding agents work -in this repository. `CLAUDE.md` is a symlink to this file, so every agent reads -the same instructions. +`tinyjevclient` is a Rust 2024 client for TypeSafe AI's System One API and Jev +model. `CLAUDE.md` is a symlink to this file. -When you generate a new project from this template, keep this file and adapt -the project-specific parts (crate name, module map, feature flags, commands). -Delete guidance that no longer applies rather than leaving it to rot. +## Workflow -## Template Checklist +- Work in a feature worktree created with `worktree `; never edit `main`. +- Preserve unrelated changes and never rewrite or squash published history. +- The auto-commit hook may checkpoint files. Do not reset those commits. +- Push feature branches and open ready-for-review PRs against the canonical + `tinyhumansai/tinyjevclient` repository. Do not merge without authorization. +- Never commit API keys. Live calls read `TYPESAFE_API_KEY` only. -Do this once, in a single commit, before writing feature code: +## Architecture -- [ ] Rename `crates/template` and `crates/template-bus` to the project's crate - names, and update `name` in each manifest plus the `template-bus` entry in - the root `[workspace.dependencies]`. -- [ ] Set `description`, `keywords`, and `categories` in each manifest, and - `repository` in the root `[workspace.package]`. -- [ ] Rename the crate references in `README.md`, both `src/lib.rs` files, - `crates/template/examples/`, and `crates/template/tests/` (search for - `template` and `template_bus`). -- [ ] Replace the placeholder `greeting` module in both crates with the first - real feature area — payload types in the contract crate, behavior in the - module crate — keeping the `mod.rs` / `types.rs` / `test.rs` layout. -- [ ] Confirm `license` and `LICENSE` match the project's intended license. -- [ ] Update the security contact in `SECURITY.md`. -- [ ] Rename the TinyBus interface, object path, and member constants in - `crates/template-bus/src/names/`, and the matching `provides` / `methods` - declarations in `crates/template/src/tinybus_module/`, while keeping - `vendor/tinybus` pinned. -- [ ] Reset `CONTRACT_VERSION` in `crates/template-bus/src/version/` for the new - contract. -- [ ] Replace `ROADMAP.md` with the real plan, or delete it. -- [ ] Rewrite the "Project Structure" section below to describe this workspace. +The virtual workspace contains one library crate at `crates/tinyjevclient`. +Feature directories under `src/` contain a `mod.rs`, substantial definitions in +`types.rs`, unit tests in `test.rs`, and a short `README.md`: -## Project Structure +- `client/`: HTTP execution, retries, secret handling, and measurements. +- `request/`: Choice, Score, Noul, and request validation. +- `response/`: answers and request-relative response validation. +- `error/`: the crate-wide `Error` and `Result` types. -This is a Rust 2024 cargo workspace rooted at a virtual `Cargo.toml`. Every -crate lives under `crates/`, one directory per package, each directory named for -the package it holds. There is no root package: the crate that ships as the -loadable module is `crates/template`, the same as any other member. +The model supplies typed judgments; application code owns policy, thresholds, +workflow control, and side effects. Do not make confidence an authorization +decision, ask Jev to generate free-form text, or hide conditional workflows in +question text. -```text -Cargo.toml # virtual workspace: members, [workspace.package], - # [workspace.dependencies], [workspace.lints] -crates/ -├── template-bus/ # the wire contract: what crosses the bus, nothing else -│ ├── README.md # why the contract is its own crate -│ └── src/ -│ ├── lib.rs # crate docs + the entire public re-export surface -│ ├── names/ # interface, object path, one constant per member -│ ├── version/ # contract version and the host bind rule -│ └── / # one directory per payload family -└── template/ # the module: behavior, adapter, and the cdylib - ├── src/ - │ ├── lib.rs # crate docs + public surface, re-exporting the contract - │ ├── error/mod.rs # crate-wide `Error` and `Result` - │ ├── tinybus_module/ # TinyBus interface, ABI exports, integration tests - │ └── / # one directory per feature area - │ ├── mod.rs # module docs, wiring, smallest useful public API - │ ├── types.rs # substantial type definitions - │ └── test.rs # module-local unit tests - ├── tests/ # integration tests against the public API only - └── examples/ # runnable, compiled-in-CI usage examples -vendor/tinybus/ # pinned TinyBus host types and module SDK -docs/ -├── specs/ # behavior and architecture specifications -├── plans/ # test-first implementation plans -└── adr/ # immutable architecture decision records -``` - -### The two-crate split - -`crates/template-bus` holds every type that crosses the bus and the names of the -members that carry them. It has no transport, no runtime, and no behavior, and -CI asserts it stays that way. A host that only makes calls depends on it alone. - -`crates/template` depends on it and re-exports all of it, so -`template::GreetRequest` and `template_bus::GreetRequest` are the *same* type -rather than structural twins. That direction is load-bearing: a parallel set of -payload types for hosts would mean a conversion at every call site that nothing -checks. - -The rule for deciding where something goes: a payload type describes what a -frame carries and belongs in the contract; anything that answers a frame, holds -a connection, or touches an engine belongs in the module crate. - -Add a crate by creating `crates//` — `members = ["crates/*"]` picks it up -by existing. Inherit `version`, `edition`, `rust-version`, `license`, and -`repository` from `[workspace.package]`, take shared dependencies from -`[workspace.dependencies]`, and opt into the shared lint set with: - -```toml -[lints] -workspace = true -``` - -Each feature area belongs in a focused module directory under a crate's `src/`. -A module root explains the module, wires its pieces together, and exposes the -smallest useful API. Move substantial type definitions into `types.rs` and put -module-local unit tests in a dedicated `test.rs`, wired from the bottom of the -module root with: - -```rust -#[cfg(test)] -mod test; -``` +## Build and Test -Do not accumulate inline `mod tests` blocks in implementation files, and do not -let a general-purpose `utils.rs` or `helpers.rs` grow — those are a symptom of a -missing module. Prefer many small modules that each do one thing well over few -broad ones. - -Keep public exports centralized in each crate's `src/lib.rs` so downstream users -have one predictable surface. Put shared error variants in -`crates/template/src/error/mod.rs` and return the crate-wide `Result` from -fallible public APIs. - -## Build And Test - -Run every command from the repository root. These four are the contract; CI -runs exactly them, so a green local run should mean a green CI run. +Run from the repository root: ```sh cargo fmt --all -- --check @@ -129,206 +39,31 @@ cargo build --all-targets --all-features cargo test --all-features ``` -Supporting commands: - -- `cargo fmt --all` — format before committing. -- `cargo test ` — run a focused subset while iterating. -- `cargo test -p template-bus` — run one crate's suite. -- `cargo run -p template --example basic` — run the bundled example. -- `cargo doc --no-deps --all-features` — build the rustdoc CI also builds with - `RUSTDOCFLAGS="-D warnings"`. -- `cargo test --doc` — run doctests alone when editing documentation examples. - -Never skip, ignore, or delete a failing test to make a command pass. Fix the -root cause, or stop and report the blocker. - -## Coding Style - -Use standard `rustfmt` output and Rust 2024 idioms. Do not hand-format around -`rustfmt`, and do not add `#[rustfmt::skip]` without a comment explaining why. - -- `snake_case` for modules, files, functions, methods, fields, and locals. -- `PascalCase` for types, traits, and enum variants; `SCREAMING_SNAKE_CASE` for - constants and statics. -- Name things for what they are, not for their layer: `RetryPolicy`, not - `RetryHelper`. -- Prefer small, typed APIs over stringly-typed ones. Accept `&str` and generic - `impl Into` at boundaries; return owned, concrete types. -- Keep the public surface minimal: default to private, and export deliberately - from `src/lib.rs`. -- `unsafe` is forbidden workspace-wide by `[workspace.lints]` in the root - `Cargo.toml`. If a project genuinely needs it, relax the lint in its own - commit and document every invariant with a `// SAFETY:` comment. - -### Errors - -- One crate-wide `Error` enum per crate, in `src/error/mod.rs`, built with - `thiserror`. -- Fallible public functions return `Result`, the crate alias. -- Add a specific variant instead of stuffing context into a string; error - messages are lowercase, without trailing punctuation. -- Do not `unwrap()`, `expect()`, or `panic!` in library code paths. They are - fine in tests, examples, and genuinely unreachable states — where `expect` - must carry a message explaining the invariant. -- Document a `# Errors` section on every public fallible function and a - `# Panics` section on anything that can panic. - -### Dependencies +CI also runs rustdoc with warnings denied, the declared MSRV build, +`cargo-deny`, and the per-file 90% line-coverage gate. The bundled example is a +paid live operation and must only be run explicitly with `TYPESAFE_API_KEY`. -Adding a dependency is a design decision. Before adding one, check whether the -standard library or an existing dependency already covers the need. When you do -add one: +## Rust Style and Errors -- pin a caret range (`serde = "1"`), not an exact version; -- enable only the features you need, with `default-features = false` when that - meaningfully trims the tree; -- gate anything optional behind a Cargo feature, documented in `Cargo.toml`; -- declare it once in the root `[workspace.dependencies]` when more than one - crate needs it, and take it with `{ workspace = true }`; -- never add one to `crates/template-bus` that pulls in a transport, an async - runtime, an HTTP client, or a native library — CI fails the build if you do; -- leave a comment above the entry explaining *why* the crate is needed and what - uses it — see the existing entries for the expected tone; -- prefer well-maintained crates with a compatible license. +- Use rustfmt and Rust 2024 idioms; unsafe code is forbidden. +- Public items require rustdoc. Fallible public functions require `# Errors`. +- Return the crate-wide `Result` and add specific `Error` variants. +- Do not use `unwrap`, `expect`, `panic`, `todo`, or `unimplemented` in library + paths. +- Keep files below 700 lines and Markdown below 500 lines. +- Pin serde wire representations in tests and cover every failure variant. +- Tests are deterministic and offline unless explicitly named and gated live. -Keep `Cargo.lock` committed; this workspace ships a single lockfile so CI and -releases are reproducible. +## Dependencies -### Vendored dependencies - -TinyBus is registered as the `vendor/tinybus` git submodule and pinned by its -gitlink. It supplies the host types and module-side SDK required to build this -crate's `cdylib`. Initialize it after cloning with: - -```sh -git submodule update --init --recursive -``` - -Do not edit vendored code from the parent repository. Make TinyBus changes in -its own repository, push them there, then update this repository's gitlink in a -separate commit. Keep the exact path dependencies and minimal features unless a -new module capability requires more. - -## Testing - -- Module-local unit tests live in `crates//src//test.rs` and may - touch private items. -- Integration tests live in `crates//tests/` and exercise only the public - API — they are the regression suite for the crate's contract. -- Payload types pin their serde representation in a unit test. That - representation is the wire form: a host and a module that disagree about a - field name fail at runtime with a decode error. -- Use descriptive, behavioral test names: `rejects_an_empty_name`, not - `test_greet_2`. -- Cover the failure paths, not just the happy path. Every new error variant - needs a test that produces it. -- For async behavior, standardize on one runtime (`tokio` as a dev-dependency - for tests) rather than mixing runtimes. -- Tests must be deterministic and independent of network, wall-clock time, and - execution order. Gate any live/network test behind a feature or an env var and - name it `live_*` so it is easy to exclude. -- Maintain at least 90% line coverage in every source file. Add or update tests - with every behavior change, and note any deliberately untested edge case in - the pull request description. - -Write the test first when fixing a bug: a failing test that reproduces the -report, then the fix that turns it green. +Dependencies use caret ranges, minimal features, and a comment in the root +workspace manifest explaining why they exist. Prefer the standard library or an +existing dependency. Keep `Cargo.lock` committed and run `cargo deny check all` +when available. ## Documentation -Write documentation for the reader who has never seen the code. - -- Every public item gets a rustdoc comment. `missing_docs` is a warning that CI - treats as an error. -- Start every `mod.rs` and `test.rs` with a concise module-level `//!` - description. -- Each crate's `src/lib.rs` carries its crate-level overview: what the crate - does, the primary entry points, and a short runnable example. It should also - say what the crate deliberately does *not* hold, and why. -- Prefer concrete examples over vague description. Doc examples are compiled and - run by `cargo test`, so they cannot drift. -- Complex modules must include a module-level `README.md` covering their design, - public surface, and important operational constraints. -- Keep `README.md`, `docs/`, and module docs aligned with code changes in the - same commit that changes behavior. -- Write accepted behavior and constraints in `docs/specs/` before creating a - linked, implementation-ordered plan in `docs/plans/`. Specs define what and - why; plans define how and in what sequence. -- Keep every Markdown file, including this one, at 500 lines or fewer. When a - topic outgrows that, split it into focused files and link them from the - nearest `README.md`. - -## Git Workflow - -- Never commit directly to `main`. Branch first, one branch per logical change. -- Do feature work in a git worktree so the main checkout stays clean. -- Commit subjects are concise and imperative: `Add retry policy to the client`. - Keep the subject specific to the change and under ~72 characters. -- Make small, focused commits. Each commit should cover one logical change, - build independently, and avoid mixing formatting, refactors, and behavior - changes unless they are inseparable. -- Never commit secrets. `.env` is git-ignored; document new variables in - `.env.example` with placeholder values. -- Never force-push a shared branch, rewrite published history, or bypass hooks - with `--no-verify`. - -## Pull Requests - -Open pull requests ready for review, not as drafts, unless the work genuinely -must not merge yet. A pull request should: - -- summarize what changed and why, in a few sentences; -- call out public API or behavior changes explicitly, or state "None"; -- list the validation commands actually run, with their outcome; -- link the related issue; -- include updated tests, docs, and examples in the same change. - -The template in `.github/PULL_REQUEST_TEMPLATE.md` encodes this checklist. -Address review feedback by fixing it, and reply on each thread describing what -changed. Do not resolve a thread whose feedback you have not addressed or -explicitly declined with a reason. - -## Releases - -Releases run from `.github/workflows/release.yml` via a manual -`workflow_dispatch` with a `patch` / `minor` / `major` bump; `current` resumes -an interrupted release after its version commit and tag exist. The workflow -re-runs the full validation suite, computes the next version, updates -the root `[workspace.package]` version and `Cargo.lock`, commits and tags -`vX.Y.Z`, builds `crates/template` as a TinyBus module for every supported -platform, pushes, and creates an immutable GitHub release with installable -native packages. - -Consequently: - -- Do not hand-edit the `version` field in the root `[workspace.package]`; the - release workflow owns it. Every member inherits it with - `version.workspace = true`, so the whole workspace releases as one version. -- Follow semantic versioning. Any change to the public surface that is not - purely additive is a breaking change and needs a major bump (pre-1.0: a minor - bump). -- The module must be packageable for every release target — `main` should - always be green. - -## Agent Working Agreement - -For automated contributors specifically: - -1. **Read before writing.** Inspect the surrounding module and match its - conventions, comment density, and idiom rather than importing a house style. -2. **Verify, do not assume.** Run the four contract commands and read their - output before reporting a task complete. Report failures with the output; - never claim a check passed that you did not run. -3. **Stay in scope.** Implement what was asked. Do not opportunistically - refactor, reformat, upgrade dependencies, or "fix" unrelated code — raise it - instead. -4. **No placeholders in delivered code.** No `todo!()`, no stubbed functions, no - commented-out alternatives left behind. If something cannot be finished, say - so explicitly. -5. **Do not weaken the guardrails.** Never add blanket `#[allow(...)]`, relax a - lint, mark a test `#[ignore]`, or loosen CI to get a green run. Fix the - cause. -6. **Secrets stay out.** Never read, echo, or commit `.env` contents, tokens, or - credentials, and never paste them into a pull request or issue. -7. **Ask only when blocked.** Make routine judgment calls yourself; escalate - only irreversible decisions or genuine forks with no clear default. +Keep `README.md`, crate docs, module READMEs, `docs/specs/`, and `docs/plans/` +aligned with behavior. Specs define accepted behavior; plans define delivery +order. The live TypeSafe documentation is the source of truth for API fields and +limits. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1424f9d..42b5700 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -7,11 +7,10 @@ this document is the short path through them. ## Development Setup Install a stable Rust toolchain with Rust 2024 support (see `rust-version` in -`Cargo.toml` for the minimum supported version), initialize the vendored -submodules, then run the four checks CI runs: +`Cargo.toml` for the minimum supported version), then run the four checks CI +runs: ```sh -git submodule update --init --recursive cargo fmt --all -- --check cargo clippy --all-targets --all-features -- -D warnings cargo build --all-targets --all-features @@ -25,10 +24,11 @@ installing `cargo-llvm-cov`, run the same gate locally: .github/scripts/check-file-coverage.sh 90 target/coverage.json ``` -The bundled example should also run: +The bundled example performs a paid live call and runs only with an explicit +credential: ```sh -cargo run --example basic +TYPESAFE_API_KEY='' cargo run --example basic ``` ## Making A Change diff --git a/Cargo.lock b/Cargo.lock index b4f454e..e2b44b7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3,36 +3,16 @@ version = 4 [[package]] -name = "adler2" -version = "2.0.1" +name = "atomic-waker" +version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" - -[[package]] -name = "arbitrary" -version = "1.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" -dependencies = [ - "derive_arbitrary", -] - -[[package]] -name = "async-trait" -version = "0.1.92" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.3", -] +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" [[package]] name = "base64" -version = "0.23.1" +version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" [[package]] name = "bitflags" @@ -69,29 +49,29 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] -name = "crc32fast" -version = "1.5.0" +name = "cfg_aliases" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" -dependencies = [ - "cfg-if", -] +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" [[package]] -name = "crossbeam-utils" -version = "0.8.22" +name = "chacha20" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +dependencies = [ + "cfg-if", + "cpufeatures", + "rand_core", +] [[package]] -name = "derive_arbitrary" -version = "1.4.2" +name = "cpufeatures" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", + "libc", ] [[package]] @@ -102,55 +82,55 @@ checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" dependencies = [ "proc-macro2", "quote", - "syn 3.0.3", + "syn", ] [[package]] -name = "equivalent" -version = "1.0.2" +name = "find-msvc-tools" +version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +checksum = "26b73573e6edcd2af0cdf47bd6cb58f0b3839491263c314eaad1ccf24430e1de" [[package]] -name = "errno" -version = "0.3.14" +name = "form_urlencoded" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" dependencies = [ - "libc", - "windows-sys 0.61.2", + "percent-encoding", ] [[package]] -name = "fastrand" -version = "2.5.0" +name = "futures-channel" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", +] [[package]] -name = "filetime" -version = "0.2.29" +name = "futures-core" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" -dependencies = [ - "cfg-if", - "libc", -] +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" [[package]] -name = "find-msvc-tools" -version = "0.1.10" +name = "futures-task" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26b73573e6edcd2af0cdf47bd6cb58f0b3839491263c314eaad1ccf24430e1de" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" [[package]] -name = "flate2" -version = "1.1.9" +name = "futures-util" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" dependencies = [ - "crc32fast", - "miniz_oxide", + "futures-core", + "futures-task", + "pin-project-lite", + "slab", ] [[package]] @@ -160,8 +140,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi", + "wasm-bindgen", ] [[package]] @@ -171,16 +153,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi", + "rand_core", + "wasm-bindgen", ] -[[package]] -name = "hashbrown" -version = "0.17.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" - [[package]] name = "http" version = "1.5.0" @@ -191,6 +170,29 @@ dependencies = [ "itoa", ] +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + [[package]] name = "httparse" version = "1.10.1" @@ -198,21 +200,191 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" [[package]] -name = "indexmap" -version = "2.14.0" +name = "hyper" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b501faa50e7a26c3d3560ca625132f4078a17771f4810baf70475ae48cbe43" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" dependencies = [ - "equivalent", - "hashbrown", + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", ] +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "icu_collections" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" + +[[package]] +name = "icu_properties" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" + +[[package]] +name = "icu_provider" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "ipnet" +version = "2.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" + [[package]] name = "itoa" version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "js-sys" +version = "0.3.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce57d20d1ea864ce2ac172ab472d409214f4fd359f0b2a2775abdf522e2af99e" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + [[package]] name = "libc" version = "0.2.189" @@ -220,10 +392,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" [[package]] -name = "linux-raw-sys" -version = "0.12.1" +name = "litemap" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" [[package]] name = "log" @@ -231,6 +403,12 @@ version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" +[[package]] +name = "lru-slab" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4050469837a6ff301cd14c1f8f24f88549e6d548f24f64e2148eb0f72cebc51f" + [[package]] name = "memchr" version = "2.8.3" @@ -238,13 +416,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" [[package]] -name = "miniz_oxide" -version = "0.8.9" +name = "mio" +version = "1.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8" dependencies = [ - "adler2", - "simd-adler32", + "libc", + "wasi", + "windows-sys 0.61.2", ] [[package]] @@ -265,6 +444,15 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + [[package]] name = "proc-macro2" version = "1.0.107" @@ -274,6 +462,62 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "quinn" +version = "0.11.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4051e23e9185c255a7e33ef59cdbca87a22d359052eecd22fc6b901fb37d9d11" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9746dbde176634f4f2f1faf2404e30a31b2bc1e9cafb5329c95d8177a18c9fc" +dependencies = [ + "bytes", + "getrandom 0.4.3", + "lru-slab", + "rand", + "rand_pcg", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.61.2", +] + [[package]] name = "quote" version = "1.0.47" @@ -289,6 +533,70 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core", +] + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots", +] + [[package]] name = "ring" version = "0.17.14" @@ -304,17 +612,10 @@ dependencies = [ ] [[package]] -name = "rustix" -version = "1.1.4" +name = "rustc-hash" +version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" -dependencies = [ - "bitflags", - "errno", - "libc", - "linux-raw-sys", - "windows-sys 0.61.2", -] +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" [[package]] name = "rustls" @@ -322,7 +623,6 @@ version = "0.23.43" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" dependencies = [ - "log", "once_cell", "ring", "rustls-pki-types", @@ -337,6 +637,7 @@ version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" dependencies = [ + "web-time", "zeroize", ] @@ -351,6 +652,18 @@ dependencies = [ "untrusted", ] +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + [[package]] name = "serde" version = "1.0.229" @@ -378,7 +691,7 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn 3.0.3", + "syn", ] [[package]] @@ -395,11 +708,14 @@ dependencies = [ ] [[package]] -name = "serde_spanned" -version = "0.6.9" +name = "serde_urlencoded" +version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" dependencies = [ + "form_urlencoded", + "itoa", + "ryu", "serde", ] @@ -410,28 +726,39 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" [[package]] -name = "simd-adler32" -version = "0.3.10" +name = "slab" +version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] -name = "subtle" -version = "2.6.1" +name = "smallvec" +version = "1.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" +checksum = "ba467056f1b547ed52077911161fc86985becbc60e8e1857c8a144dab0def891" [[package]] -name = "syn" -version = "2.0.119" +name = "socket2" +version = "0.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", + "libc", + "windows-sys 0.61.2", ] +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "syn" version = "3.0.3" @@ -444,47 +771,23 @@ dependencies = [ ] [[package]] -name = "tar" -version = "0.4.46" +name = "sync_wrapper" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" dependencies = [ - "filetime", - "libc", - "xattr", + "futures-core", ] [[package]] -name = "tempfile" -version = "3.27.0" +name = "synstructure" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" -dependencies = [ - "fastrand", - "getrandom 0.4.3", - "once_cell", - "rustix", - "windows-sys 0.61.2", -] - -[[package]] -name = "template" -version = "0.2.1" +checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" dependencies = [ - "serde_json", - "template-bus", - "thiserror", - "tinybus", - "tinybus-module", - "tokio", -] - -[[package]] -name = "template-bus" -version = "0.2.1" -dependencies = [ - "serde", - "serde_json", + "proc-macro2", + "quote", + "syn", ] [[package]] @@ -504,48 +807,35 @@ checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" dependencies = [ "proc-macro2", "quote", - "syn 3.0.3", + "syn", ] [[package]] -name = "tinybus" -version = "0.1.0" +name = "tinyjevclient" +version = "0.2.1" dependencies = [ - "async-trait", - "flate2", + "reqwest", "serde", "serde_json", - "tar", - "tempfile", "thiserror", - "tinybus-macros", "tokio", - "toml", - "tracing", - "ureq", - "zip", ] [[package]] -name = "tinybus-macros" -version = "0.1.0" +name = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", + "displaydoc", + "zerovec", ] [[package]] -name = "tinybus-module" -version = "0.1.0" -dependencies = [ - "async-trait", - "serde", - "serde_json", - "tinybus", - "tokio", - "tracing", -] +name = "tinyvec" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" [[package]] name = "tokio" @@ -554,8 +844,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" dependencies = [ "bytes", + "libc", + "mio", "pin-project-lite", + "socket2", "tokio-macros", + "windows-sys 0.61.2", ] [[package]] @@ -566,49 +860,63 @@ checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" dependencies = [ "proc-macro2", "quote", - "syn 3.0.3", + "syn", ] [[package]] -name = "toml" -version = "0.8.23" +name = "tokio-rustls" +version = "0.26.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +checksum = "b0c85f2c3ef0b1cd58b36682f4b17aaa995f0e5db534d85692b4903abce21f67" dependencies = [ - "serde", - "serde_spanned", - "toml_datetime", - "toml_edit", + "rustls", + "tokio", ] [[package]] -name = "toml_datetime" -version = "0.6.11" +name = "tower" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" dependencies = [ - "serde", + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", ] [[package]] -name = "toml_edit" -version = "0.22.27" +name = "tower-http" +version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ - "indexmap", - "serde", - "serde_spanned", - "toml_datetime", - "toml_write", - "winnow", + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", ] [[package]] -name = "toml_write" -version = "0.1.2" +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" [[package]] name = "tracing" @@ -617,21 +925,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ "pin-project-lite", - "tracing-attributes", "tracing-core", ] -[[package]] -name = "tracing-attributes" -version = "0.1.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - [[package]] name = "tracing-core" version = "0.1.36" @@ -641,6 +937,12 @@ dependencies = [ "once_cell", ] +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + [[package]] name = "unicode-ident" version = "1.0.24" @@ -654,39 +956,31 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" [[package]] -name = "ureq" -version = "3.4.0" +name = "url" +version = "2.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "972d7902c8735f2695410b8aed7df6ed12a47394aa1c8d7af49f0497b731a94d" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" dependencies = [ - "base64", - "flate2", - "log", + "form_urlencoded", + "idna", "percent-encoding", - "rustls", - "rustls-pki-types", - "ureq-proto", - "utf8-zero", - "webpki-roots", + "serde", ] [[package]] -name = "ureq-proto" -version = "0.6.1" +name = "utf8_iter" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da5f78b09e6941e1a0f2e30e695e4b120377b54d5e0aec11b594bb57b3971613" -dependencies = [ - "base64", - "http", - "httparse", - "log", -] +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" [[package]] -name = "utf8-zero" -version = "0.8.1" +name = "want" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] [[package]] name = "wasi" @@ -694,6 +988,81 @@ version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" +[[package]] +name = "wasm-bindgen" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.78" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ef4c5d3d2cdf5c54f4231181768f5510842e350db025faf1f7163b1030ed928" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a690d511e3c1a8b3a55e33511e3c2c00c78415cd23650f32b808627f5696b9ed" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "411e4887f0071ef2d2164a9d5fdf2d20efbef78fccd3a78b0c10a1dc5295e48a" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81941cd78d0c92026c33e5e01312845a4cb1e9af3407f9134b100dd03144103e" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fbddc4a036f00ec4f18c83445bd3115cb306a91da554919a099d9222fe4a7f8" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "webpki-roots" version = "1.0.9" @@ -792,22 +1161,53 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" [[package]] -name = "winnow" -version = "0.7.15" +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "yoke" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" dependencies = [ - "memchr", + "stable_deref_trait", + "yoke-derive", + "zerofrom", ] [[package]] -name = "xattr" -version = "1.6.1" +name = "yoke-derive" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +checksum = "33811428bee40dbceb6d545e95754741d17a6aef9a4849f0fd62e2ba4f412a78" dependencies = [ - "libc", - "rustix", + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", ] [[package]] @@ -817,36 +1217,40 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" [[package]] -name = "zip" -version = "2.4.2" +name = "zerotrie" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" dependencies = [ - "arbitrary", - "crc32fast", - "crossbeam-utils", "displaydoc", - "flate2", - "indexmap", - "memchr", - "thiserror", - "zopfli", + "yoke", + "zerofrom", ] [[package]] -name = "zmij" -version = "1.0.23" +name = "zerovec" +version = "0.11.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] [[package]] -name = "zopfli" -version = "0.8.3" +name = "zerovec-derive" +version = "0.11.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" dependencies = [ - "bumpalo", - "crc32fast", - "log", - "simd-adler32", + "proc-macro2", + "quote", + "syn", ] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml index fae3c38..5f5e862 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,18 +1,10 @@ [workspace] resolver = "3" -# Every crate in this repository lives under `crates/`, one directory per -# package, each directory named for the package it holds. There is no root -# package: the crate a host loads is `crates/template`, the same as any other -# member. Keeping the root virtual is what makes that uniform — a root package -# would make one crate structurally different from the rest for no reason other -# than history, and it is the arrangement this template moved away from. +# The HTTP client is the repository's only package. The root stays virtual so +# examples, tests, and documentation all exercise the same library artifact. members = ["crates/*"] -# `vendor/` holds the pinned TinyBus submodule, which is its own workspace with -# its own lockfile. `worktrees/` holds `git worktree` checkouts of this same -# repository; each contains a full copy of this manifest and every crate under -# it, so without this entry cargo walks into them and reports duplicate -# packages. -exclude = ["vendor", "worktrees"] +# Worktrees contain another copy of this manifest and must not become members. +exclude = ["worktrees"] # Shared package metadata. A member inherits a field with `field.workspace = # true`, so the version the release workflow bumps is written in exactly one @@ -22,35 +14,21 @@ version = "0.2.1" edition = "2024" rust-version = "1.88" license = "GPL-3.0-only" -repository = "https://github.com/tinyhumansai/rust-template" +repository = "https://github.com/tinyhumansai/tinyjevclient" [workspace.dependencies] -# The wire contract. `crates/template` depends on it and re-exports it, so a -# host that only makes calls takes this crate alone. -# No `version` requirement on purpose: the workspace version moves on every -# release, and a pinned requirement here would stop resolving the moment it did. -# Nothing in this workspace is published, so the path is the whole address. -template-bus = { path = "crates/template-bus" } -# TinyBus defines the message types, interface macro, and frozen module ABI -# used by the generated integration. Socket and CLI features are unnecessary -# here. -tinybus = { path = "vendor/tinybus/crates/tinybus", version = "0.1.0", default-features = false, features = [ - "macros", - "modules", -] } -# The module-side SDK owns the isolated runtime and exports the ABI entrypoints -# required by TinyBus's dynamic loader. -tinybus-module = { path = "vendor/tinybus/crates/tinybus-module", version = "0.1.0" } -# Derive macros for the crate-wide error type in `crates/template/src/error/`. +# Derive macros for the crate-wide error type. # Every dependency entry should carry a comment like this one saying why it is # here. thiserror = "2" -# The bus payload types are serialized into TinyBus frames. +# Requests and responses use the TypeSafe System One JSON wire contract. serde = { version = "1", features = ["derive"] } -# Positional argument arrays and the module configuration blob. +# State and structured instructions may contain arbitrary JSON. serde_json = "1" -# Module integration tests exercise the real asynchronous in-memory TinyBus. -tokio = { version = "1", features = ["macros", "rt-multi-thread", "time"] } +# The client uses a rustls-backed HTTP transport and no platform TLS library. +reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } +# Bounded retry delays are asynchronous and inherit the caller's Tokio runtime. +tokio = { version = "1", features = ["time"] } # Lints apply to every member that opts in with `[lints] workspace = true`, and # to every target of that member. CI runs clippy with `-D warnings`, so anything diff --git a/MODULE.md b/MODULE.md deleted file mode 100644 index 651906e..0000000 --- a/MODULE.md +++ /dev/null @@ -1,31 +0,0 @@ -# Template TinyBus Module - -This package contains the native `template` module for TinyBus module ABI -v1. Install only the archive matching the host operating system and -architecture. - -The module claims `ai.tinyhumans.template.Greeting`, serves the object at -`/ai/tinyhumans/template/Greeting`, and provides the `Greet` method. The -method accepts a `GreetRequest` and returns a `GreetResponse` carrying -`Hello, !`; empty names are rejected. Both payload types, the interface -name, the object path, and the member names are published as the `template-bus` -crate, so a host names them from a library rather than by string literal. - -The archive contains one `.so`, `.dylib`, or `.dll` plus `modules.toml`. Keep -those files together when copying them into a TinyBus module directory. The -allowlist binds the native library filename to its SHA-256 digest so TinyBus can -reject a missing, renamed, or modified artifact before initialization. - -The GitHub release also publishes `checksum.toml` as a separate asset. TinyBus -checks that manifest before downloading and extracting the selected platform -archive. Install directly from a tagged release with: - -```sh -tinybus modules load-github \ - https://github.com/tinyhumansai/rust-template/releases/tag/v0.1.5 \ - template-0.1.5-ubuntu-24.04-x86_64.tar.gz \ - -``` - -TinyBus modules are trusted in-process code. Install release artifacts only -from a trusted source and restart the host after replacing a loaded module. diff --git a/README.md b/README.md index 67a4e39..870a836 100644 --- a/README.md +++ b/README.md @@ -1,156 +1,47 @@ -# Rust Template - -A production-ready Rust 2024 TinyBus module template used by TinyHumans AI. It -ships the workspace layout, TinyBus ABI adapter, error handling, testing, -documentation, CI, and multi-platform release workflow that every new -integration in this organization starts from. - -It is a two-crate cargo workspace. `crates/template-bus` is the wire contract — -member names, payload types, and the contract version, with no transport and no -behavior — and `crates/template` is the implementation, built as both an `rlib` -and the `cdylib` TinyBus loads. A host that only makes calls depends on the -contract crate alone and compiles neither the module nor `tinybus` itself. - -## Use This Template - -Choose **Use this template** on GitHub, create a repository, then work through -the checklist at the top of [`AGENTS.md`](AGENTS.md): - -- rename the `crates/template` and `crates/template-bus` directories and the - `name` fields in their manifests, and set the shared `description`, - `repository`, `keywords`, and `categories`; -- update this README and the crate documentation in `crates/template/src/lib.rs`; -- replace the placeholder `greeting` module with the first real feature area, in - both crates: the payload types in the contract, the behavior in the module; -- rename the TinyBus interface, object path, and member constants in - `crates/template-bus/src/names/`, and the matching `provides` / `methods` - declarations in `crates/template/src/tinybus_module/`; -- update the security contact and repository links in the community files; -- replace `ROADMAP.md` with the real plan, or delete it; -- change the license if GPL-3.0-only is not appropriate. - -Search for `template` and `template_bus` to find every remaining -template-specific value. - -## What You Get - -| Area | What is configured | -| --- | --- | -| Layout | A cargo workspace under `crates/`, split into a dependency-light wire contract and the module that implements it; directory modules with `mod.rs` / `types.rs` / `test.rs`, a crate-wide error type, integration tests, and a runnable example | -| Lints | `unsafe_code` forbidden, `missing_docs`, clippy `all` + `pedantic`, no `unwrap`/`expect`/`panic`/`todo` in library code — all declared once in `[workspace.lints]` so every crate, local run, and CI run agree | -| CI | Format, clippy, build, test (default and all features), a run of the bundled example, an assertion that the contract crate stays transport-free, at least 90% line coverage in every source file, rustdoc with `-D warnings`, an MSRV build, and a `cargo-deny` supply-chain check | -| Release | Manual `workflow_dispatch` bump that validates, versions, tags, and creates installable native module packages for every supported platform | -| Community | Issue and pull request templates, Dependabot, contributing, security, support, and code of conduct docs | -| Agents | [`AGENTS.md`](AGENTS.md) as the single source of truth, symlinked as `CLAUDE.md`, plus a `.claude/settings.json` allowlist for the standard commands | -| Vendor | TinyBus host types and module SDK pinned as the `vendor/tinybus` build-time submodule | - -## Layout - -```text -Cargo.toml # virtual workspace: members, shared metadata, lints -crates/ -├── template-bus/ # the wire contract — what crosses the bus -│ ├── README.md # why the contract is its own crate -│ └── src/ -│ ├── lib.rs # crate docs + the entire public re-export surface -│ ├── names/ # interface, object path, one constant per member -│ ├── greeting/ # payload types, one directory per family -│ │ ├── mod.rs -│ │ ├── types.rs -│ │ └── test.rs -│ └── version/ # contract version and the host bind rule -└── template/ # the module — behavior, adapter, and the cdylib - ├── src/ - │ ├── lib.rs # crate docs + public surface, re-exporting the contract - │ ├── error/ # crate-wide `Error` and `Result` - │ ├── greeting/ # one directory per feature area - │ └── tinybus_module/ # bus interface, setup, and ABI v1 exports - ├── tests/ - │ └── public_api.rs # integration tests against the public API only - └── examples/ - ├── basic.rs # ordinary library API usage - ├── verify_module.rs # local dynamic-module verification - └── verify_github_release.rs # tagged-release download and bus call -vendor/ -└── tinybus/ # pinned TinyBus git submodule -docs/ -├── README.md # documentation index and conventions -├── specs/ # behavior and architecture specifications -├── plans/ # implementation-ordered delivery plans -└── adr/ # immutable architecture decision records +# TinyJevClient + +`tinyjevclient` is a typed Rust client for TypeSafe AI's System One API and +Jev model. It sends shared state with independent Choice, Score, and Noul +questions, validates the provider's response against the originating request, +and returns latency, attempts, usage, and request metadata alongside the typed +answers. + +The client keeps execution and policy outside the model. A Choice selects only +from caller-supplied values, a Score rates one described dimension, and a Noul +reports the probability of a yes/no condition. Callers own confidence +thresholds, escalation, state transitions, and side effects. + +```rust,no_run +use std::collections::BTreeMap; +use serde_json::json; +use tinyjevclient::{Choice, Client, EvaluationRequest, Question}; + +# async fn run() -> tinyjevclient::Result<()> { +let request = EvaluationRequest::jev( + json!({"ticket": "I was charged twice"}), + BTreeMap::from([( + "route".to_owned(), + Question::Choice(Choice { + instructions: json!("Which team should handle this ticket?"), + criteria: BTreeMap::from([ + ("billing".to_owned(), None), + ("technical".to_owned(), None), + ]), + }), + )]), +); +let result = Client::from_env()?.evaluate(&request).await?; +println!("{:?}", result.response.answers["route"]); +# Ok(()) +# } ``` -The split is the point. A payload type describes what a frame carries; the -behavior that answers it is a different obligation. `template` depends on -`template-bus` and re-exports all of it, so `template::GreetRequest` and -`template_bus::GreetRequest` are the *same* type rather than structural twins, -and a host is never forced to choose between linking the whole module and -redefining the vocabulary. See -[`crates/template-bus/README.md`](crates/template-bus/README.md). - -Within each crate, feature areas use directory modules: implementation and -exports live in `mod.rs`, substantial types move to `types.rs`, and unit tests -live in `test.rs`. [`AGENTS.md`](AGENTS.md) holds the complete repository -guidance, and `CLAUDE.md` is a symlink to it so every coding agent reads one -source of truth. - -## Development - -Clone with submodules, or initialize them before building: +The API key is read from `TYPESAFE_API_KEY` or supplied to `ClientConfig`. It is +redacted from `Debug` and never included in errors. The live example spends a +real API call: ```sh -git submodule update --init --recursive +TYPESAFE_API_KEY='' cargo run -p tinyjevclient --example basic ``` -```sh -cargo fmt --all -- --check -cargo clippy --all-targets --all-features -- -D warnings -cargo build --all-targets --all-features -cargo test --all-features -cargo run -p template --example basic -cargo build -p template --release --lib # produces the installable cdylib -``` - -Those four checks are exactly what CI runs. Optional extras: - -```sh -cargo doc --no-deps --all-features # CI builds this with RUSTDOCFLAGS="-D warnings" -cargo deny check all # supply-chain check; see deny.toml -cargo install cargo-llvm-cov # once, before running the coverage gate -.github/scripts/check-file-coverage.sh 90 coverage.json -``` - -## Releasing - -Run the **Release** workflow from the Actions tab with a `patch`, `minor`, or -`major` bump. Use `current` only to resume an interrupted release whose version -commit and tag already exist. The workflow revalidates the workspace, versions -and tags it — one `[workspace.package]` version that every member inherits — -builds `crates/template` as a TinyBus `cdylib`, and creates a GitHub release. -Assets follow `template--.` and contain the -native module, its SHA-256 `modules.toml`, license, and -[`MODULE.md`](MODULE.md). Every release also publishes `checksum.toml`, which -TinyBus uses to verify an archive before extraction. The workflow loads the -published Ubuntu archive through TinyBus's GitHub release API and calls its -`Greet` method before declaring the release successful. TinyBus itself is not -shipped by this repository; the pinned submodule is the build-time SDK. The stable native -matrix covers Ubuntu 22.04 and 24.04 on x86_64 and ARM64; Fedora 43 and 44 on -x86_64 and ARM64; rolling Arch Linux on its officially supported x86_64 -architecture; macOS 15 and 26 on Intel and Apple Silicon; Windows Server 2022 -and 2025 on x86_64; and Windows 11 on ARM64. Preview, deprecated, and unofficial -architecture images are not release gates. Do not hand-edit the version in the -root `Cargo.toml`. - -## Documentation - -- [`AGENTS.md`](AGENTS.md) — repository guidelines for humans and agents -- [`CONTRIBUTING.md`](CONTRIBUTING.md) — how to propose a change -- [`docs/specs/`](docs/specs/README.md) — behavior and architecture specs -- [`docs/plans/`](docs/plans/README.md) — test-first implementation plans -- [`docs/adr/`](docs/adr/0001-record-architecture-decisions.md) — architecture - decision records -- [`SECURITY.md`](SECURITY.md) — how to report a vulnerability - -## License - -GPL-3.0-only. See [LICENSE](LICENSE). +The repository is GPL-3.0-only and is consumed by pinned git revision. diff --git a/ROADMAP.md b/ROADMAP.md index 1134024..3ba36c5 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,26 +1,19 @@ # Roadmap -Replace this file with the real plan for the crate generated from this -template, or delete it if the project does not need a public roadmap. - -Keep it short and honest: what exists, what is next, and what is deliberately -out of scope. A roadmap that lists everything is a roadmap nobody trusts. - ## Shipped -- module layout, crate-wide error type, and the public re-export surface -- lint configuration in `[lints]`, enforced identically locally and in CI -- CI: format, clippy, build, test, per-file coverage, rustdoc, MSRV, and - supply-chain checks -- a manual release workflow that versions, tags, publishes to crates.io, and - creates a GitHub release with crate and TinyBus runtime/module assets +- typed Choice, Score, and Noul request/response contracts +- request-relative response validation +- rustls HTTP client with classified failures and bounded retries +- redacted credentials and measured usage, attempts, request id, and latency ## Next -- the first real feature area, replacing the placeholder `greeting` module -- module-level `README.md` and `docs/spec/` entries as modules grow +- production evidence from the TinyHiveMind paired benchmark +- API additions only when a real integration requires them -## Out Of Scope +## Out of scope -- anything that cannot be tested deterministically -- convenience wrappers that hide the crate's error taxonomy from callers +- executing selected actions +- choosing application confidence thresholds +- generating free-form text diff --git a/crates/template-bus/Cargo.toml b/crates/template-bus/Cargo.toml deleted file mode 100644 index a30dd85..0000000 --- a/crates/template-bus/Cargo.toml +++ /dev/null @@ -1,24 +0,0 @@ -[package] -name = "template-bus" -version.workspace = true -edition.workspace = true -rust-version.workspace = true -license.workspace = true -repository.workspace = true -description = "The TinyBus wire contract for the template module: member names, payload types, and the contract version." -documentation = "https://docs.rs/template-bus" -readme = "README.md" -keywords = ["tinybus", "module", "contract", "template"] -categories = ["development-tools"] -publish = false - -# Deliberately dependency-light: this is the crate a host links to talk to the -# loadable module, so it must cost that host almost nothing. Nothing here may -# pull in `tinybus`, an async runtime, an HTTP client, or a native library — -# see `src/lib.rs` for why the transport in particular is absent. CI asserts it. -[dependencies] -serde = { workspace = true } -serde_json = { workspace = true } - -[lints] -workspace = true diff --git a/crates/template-bus/README.md b/crates/template-bus/README.md deleted file mode 100644 index 7f8e99c..0000000 --- a/crates/template-bus/README.md +++ /dev/null @@ -1,100 +0,0 @@ -# template-bus - -Every type that crosses the template module's `TinyBus` boundary, and the names -of the members that carry them. - -The template ships as a loadable module so a host does not compile the -implementation: `crates/template` is built as a `cdylib` and exports one object. -A host can load that binary but cannot `use` anything out of it, so the payload -vocabulary has to be published as an ordinary library. This is it. - -| module | what it holds | -| ---------- | ------------------------------------------------------------ | -| `names` | interface name, object path, one constant per member | -| `greeting` | the value vocabulary: the `Greet` request and response | -| `version` | `CONTRACT_VERSION` and the bind rule a host applies to it | - -Two dependencies, both pure Rust: `serde` and `serde_json`. - -## This crate sits underneath `template` - -`template` **depends on this crate and re-exports all of it**. That direction -matters, and it is the opposite of the obvious one. - -A *host* needs the payload types and needs nothing else: it loads the module and -makes calls, so it names `GreetRequest` and `GreetResponse` but implements no -behavior and links no transport. Making it depend on the whole module crate — and -through it on `tinybus`, `tokio`, and the module SDK — to spell a payload type -would be the wrong shape. - -The alternative, a parallel set of payload types for hosts, is worse: a -`GreetRequest` defined twice is two distinct types, with a conversion at every -call site that nothing checks. One definition, here, at the bottom. - -Because the re-export is by module as well as by item, `template::GreetRequest`, -`template::names::OBJECT_PATH`, and `template_bus::greeting::GreetRequest` all -resolve to the same items, not twins. - -So: a module author depends on `template` and gets behavior and vocabulary. A -host depends on `template-bus` and gets vocabulary alone. - -## What is deliberately absent - -**No behavior.** `greet` lives in `crates/template`. A payload type describes -what a frame carries, not what the module does with it. The split is readable -off the path: a name here is data, a name there is an obligation. - -**No transport.** This crate does not depend on `tinybus` and holds no -connection, client, or codec. A host already owns its connection — its reconnect -policy, its timeouts, its tracing — and the useful part is the vocabulary. - -That is also structural, not just preference: `tinybus` is vendored as a -submodule whose manifest inherits fields from its own nested -`[workspace.package]`. Keeping the contract crate transport-free is what keeps -it down to two dependencies and what lets anything in the workspace — or outside -it — depend on it freely. CI asserts the dependency tree stays that way. - -## Making a call - -Arguments travel as a positional JSON array — `#[tinybus::interface]` decodes -them into a tuple — and the member name comes from `names`: - -```rust,ignore -use template_bus::{names, GreetRequest, GreetResponse}; - -let proxy = connection.proxy(names::INTERFACE, names::OBJECT_PATH, names::INTERFACE)?; -let reply: GreetResponse = proxy - .call(names::methods::GREET, (GreetRequest::new("Ferris"),)) - .await?; -assert_eq!(reply.greeting, "Hello, Ferris!"); -``` - -Nothing above is a string literal at a call site. Renaming the interface, the -path, or a member is therefore a compile error in every consumer rather than an -`UnknownMethod` discovered at runtime. - -## Staying in step with the module - -`names::METHODS` lists every member in dispatch order. `crates/template` asserts -its served members against that list, so a method added to the interface without -an entry here fails that crate's tests rather than surfacing in a host. - -## Versioning - -`CONTRACT_VERSION` describes *this vocabulary*, not the package. Bump its major -component when a payload's wire form changes incompatibly or a member is removed -or renamed, and its minor component when a member or an optional field is added. -It is deliberately independent of the package version the release workflow owns, -which tracks the shipped artifact. - -The payload tests pin the serde representation, because that representation is -the wire form: a host and a module that disagree about a field name fail at -runtime with a decode error, so the shape is asserted rather than assumed. - -## Generating a project from the template - -Rename the interface, the object path, and the member constants in `names` -together, replace `greeting` with the first real payload family, and reset -`CONTRACT_VERSION` to `(1, 0)` for the new contract. Keep the crate -dependency-light: the moment it links a transport or a runtime, the reason it -exists is gone. diff --git a/crates/template-bus/src/greeting/mod.rs b/crates/template-bus/src/greeting/mod.rs deleted file mode 100644 index f810aab..0000000 --- a/crates/template-bus/src/greeting/mod.rs +++ /dev/null @@ -1,17 +0,0 @@ -//! The payloads the `Greet` member exchanges. -//! -//! A module root like this one documents the module, wires its pieces together, -//! and exposes the smallest useful API. The type definitions live in the -//! sibling `types.rs`, and the unit tests in `test.rs`, wired in at the bottom -//! of this file. -//! -//! Replace this module with the first real payload family the module carries. -//! Payload types are `serde`-derived, `#[non_exhaustive]`, and hold owned data: -//! they are decoded from a frame, so they can borrow nothing from the caller. - -mod types; - -pub use types::{GreetRequest, GreetResponse}; - -#[cfg(test)] -mod test; diff --git a/crates/template-bus/src/greeting/test.rs b/crates/template-bus/src/greeting/test.rs deleted file mode 100644 index 1a30000..0000000 --- a/crates/template-bus/src/greeting/test.rs +++ /dev/null @@ -1,65 +0,0 @@ -//! Unit tests for the `Greet` payloads. -//! -//! These pin the serde representation. It is the wire form: a host and a module -//! that disagree about a field name fail at runtime with a decode error, so the -//! shape is asserted here rather than assumed. - -#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] - -use super::{GreetRequest, GreetResponse}; - -#[test] -fn a_request_serializes_to_its_wire_form() { - let encoded = serde_json::to_value(GreetRequest::new("Ferris")).unwrap(); - assert_eq!(encoded, serde_json::json!({ "name": "Ferris" })); -} - -#[test] -fn a_response_serializes_to_its_wire_form() { - let encoded = serde_json::to_value(GreetResponse::new("Hello, Ferris!")).unwrap(); - assert_eq!(encoded, serde_json::json!({ "greeting": "Hello, Ferris!" })); -} - -#[test] -fn a_request_round_trips_through_json() { - let request = GreetRequest::new(" Ferris "); - let encoded = serde_json::to_string(&request).unwrap(); - assert_eq!( - serde_json::from_str::(&encoded).unwrap(), - request - ); -} - -#[test] -fn a_response_round_trips_through_json() { - let response = GreetResponse::new("Hello, Ferris!"); - let encoded = serde_json::to_string(&response).unwrap(); - assert_eq!( - serde_json::from_str::(&encoded).unwrap(), - response - ); -} - -#[test] -fn a_request_missing_its_name_is_rejected() { - let decoded = serde_json::from_value::(serde_json::json!({})); - assert!(decoded.is_err()); -} - -#[test] -fn a_response_missing_its_greeting_is_rejected() { - let decoded = serde_json::from_value::(serde_json::json!({})); - assert!(decoded.is_err()); -} - -#[test] -fn constructors_accept_both_borrowed_and_owned_names() { - assert_eq!( - GreetRequest::new(String::from("Ferris")), - GreetRequest::new("Ferris") - ); - assert_eq!( - GreetResponse::new(String::from("Hi")), - GreetResponse::new("Hi") - ); -} diff --git a/crates/template-bus/src/greeting/types.rs b/crates/template-bus/src/greeting/types.rs deleted file mode 100644 index d70b376..0000000 --- a/crates/template-bus/src/greeting/types.rs +++ /dev/null @@ -1,54 +0,0 @@ -//! Request and response types for the `Greet` member. - -use serde::{Deserialize, Serialize}; - -/// The argument to [`crate::names::methods::GREET`]. -/// -/// The module trims surrounding whitespace from [`GreetRequest::name`] and -/// rejects a name that is empty once trimmed. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[non_exhaustive] -pub struct GreetRequest { - /// The name to greet. - pub name: String, -} - -impl GreetRequest { - /// Builds a request greeting `name`. - /// - /// # Examples - /// - /// ``` - /// # use template_bus::GreetRequest; - /// assert_eq!(GreetRequest::new("Ferris").name, "Ferris"); - /// ``` - #[must_use] - pub fn new(name: impl Into) -> Self { - Self { name: name.into() } - } -} - -/// The reply from [`crate::names::methods::GREET`]. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[non_exhaustive] -pub struct GreetResponse { - /// The rendered greeting. - pub greeting: String, -} - -impl GreetResponse { - /// Builds a reply carrying `greeting`. - /// - /// # Examples - /// - /// ``` - /// # use template_bus::GreetResponse; - /// assert_eq!(GreetResponse::new("Hello, Ferris!").greeting, "Hello, Ferris!"); - /// ``` - #[must_use] - pub fn new(greeting: impl Into) -> Self { - Self { - greeting: greeting.into(), - } - } -} diff --git a/crates/template-bus/src/lib.rs b/crates/template-bus/src/lib.rs deleted file mode 100644 index a1857d1..0000000 --- a/crates/template-bus/src/lib.rs +++ /dev/null @@ -1,74 +0,0 @@ -//! Every type that crosses the template module's `TinyBus` boundary, and the -//! names of the members that carry them. -//! -//! This crate ships as a loadable `TinyBus` module: `crates/template` is built -//! as a `cdylib` and exports one object. A host that loads that binary can call -//! into it but cannot `use` anything out of it, so the payload vocabulary has -//! to be published as an ordinary library. This is that library. -//! -//! # What is here -//! -//! - [`names`] — the interface name, the object path, and one constant per -//! member, plus [`names::METHODS`] listing them in dispatch order. -//! - [`greeting`] — the value vocabulary: the request and response payloads the -//! `Greet` member exchanges. -//! - [`version`] — [`CONTRACT_VERSION`] and the [`is_compatible`] bind rule. -//! -//! # What is deliberately not here -//! -//! **No behavior.** The `greet` implementation lives in `crates/template`, -//! which depends on this crate and re-exports it. A payload type describes what -//! a frame carries, not what the module does with it. -//! -//! **No transport.** This crate does not depend on `tinybus` and holds no -//! connection, client, or codec. A host already owns its connection — its -//! reconnect policy, its timeouts, its tracing — and the useful part is the -//! vocabulary, not another wrapper around it. -//! -//! That is also a structural necessity, not only a preference: `tinybus` is -//! vendored as a submodule whose manifest inherits fields from its own nested -//! `[workspace.package]`. A crate that every workspace member can depend on has -//! to stay transport-free, and staying transport-free is what keeps this crate -//! down to two pure-Rust dependencies. -//! -//! # This crate sits underneath the implementation, not beside it -//! -//! `template` **depends on this crate and re-exports all of it**, so -//! `template::GreetRequest` and `template_bus::greeting::GreetRequest` are the -//! *same type*, not structural twins. Defining a parallel set of payload types -//! for hosts would mean a conversion at every call site that nothing checks. -//! One definition, here, at the bottom. -//! -//! So: a module author depends on `template` and gets behavior and vocabulary. -//! A host depends on `template-bus` and gets vocabulary alone. -//! -//! # Staying in step with the module -//! -//! [`names::METHODS`] lists every member. `crates/template` asserts its served -//! members against that list, in order, so a method added to the interface -//! without an entry here fails that crate's tests rather than surfacing as an -//! unknown method in a host at runtime. -//! -//! # Example -//! -//! ``` -//! use template_bus::{names, GreetRequest, GreetResponse}; -//! -//! let body = serde_json::to_value([GreetRequest::new("Ferris")])?; -//! assert_eq!(names::methods::GREET, "Greet"); -//! assert_eq!(names::OBJECT_PATH, "/ai/tinyhumans/template/Greeting"); -//! -//! let reply: GreetResponse = serde_json::from_value( -//! serde_json::json!({ "greeting": "Hello, Ferris!" }), -//! )?; -//! assert_eq!(reply.greeting, "Hello, Ferris!"); -//! # Ok::<(), serde_json::Error>(()) -//! ``` - -pub mod greeting; -pub mod names; -pub mod version; - -pub use greeting::{GreetRequest, GreetResponse}; -pub use names::{INTERFACE, METHODS, OBJECT_PATH}; -pub use version::{CONTRACT_VERSION, is_compatible}; diff --git a/crates/template-bus/src/names/mod.rs b/crates/template-bus/src/names/mod.rs deleted file mode 100644 index 4da1547..0000000 --- a/crates/template-bus/src/names/mod.rs +++ /dev/null @@ -1,33 +0,0 @@ -//! The bus identity of the template module: interface name, object path, and -//! one constant per member. -//! -//! Nothing here is a string literal at a call site. A host names a member -//! through [`methods`] and the object through [`OBJECT_PATH`], so a rename is a -//! compile error in every consumer rather than a runtime "unknown method". -//! -//! When generating a project from this template, rename all three together — -//! the interface, the path, and the member constants — and keep -//! [`METHODS`] in the same order as the interface's dispatch table. - -/// The well-known interface name the module claims on the bus. -pub const INTERFACE: &str = "ai.tinyhumans.template.Greeting"; - -/// The object path the module serves its interface at. -pub const OBJECT_PATH: &str = "/ai/tinyhumans/template/Greeting"; - -/// One constant per member of [`INTERFACE`]. -pub mod methods { - /// Builds a greeting for a name. - /// - /// Takes a [`crate::GreetRequest`] and returns a [`crate::GreetResponse`]. - pub const GREET: &str = "Greet"; -} - -/// Every member of [`INTERFACE`], in the order the interface dispatches them. -/// -/// `crates/template` asserts its declared manifest methods against this list, -/// so the two cannot drift. -pub const METHODS: &[&str] = &[methods::GREET]; - -#[cfg(test)] -mod test; diff --git a/crates/template-bus/src/names/test.rs b/crates/template-bus/src/names/test.rs deleted file mode 100644 index bf7bea2..0000000 --- a/crates/template-bus/src/names/test.rs +++ /dev/null @@ -1,28 +0,0 @@ -//! Unit tests for the bus name table. - -use super::{INTERFACE, METHODS, OBJECT_PATH, methods}; - -#[test] -fn the_object_path_is_the_interface_in_path_form() { - let expected = format!("/{}", INTERFACE.replace('.', "/")); - assert_eq!(OBJECT_PATH, expected); -} - -#[test] -fn every_member_is_listed_exactly_once() { - let mut sorted = METHODS.to_vec(); - sorted.sort_unstable(); - let mut deduplicated = sorted.clone(); - deduplicated.dedup(); - assert_eq!(sorted, deduplicated); -} - -#[test] -fn the_method_table_holds_the_declared_members() { - assert_eq!(METHODS, [methods::GREET]); -} - -#[test] -fn no_member_name_is_empty() { - assert!(METHODS.iter().all(|method| !method.is_empty())); -} diff --git a/crates/template-bus/src/version/mod.rs b/crates/template-bus/src/version/mod.rs deleted file mode 100644 index ada372d..0000000 --- a/crates/template-bus/src/version/mod.rs +++ /dev/null @@ -1,46 +0,0 @@ -//! The contract version, and the rule a host uses to decide whether it can bind -//! to a module that reports one. -//! -//! The version describes *this vocabulary*, not the crate: bump the major -//! component when a payload's wire form changes incompatibly or a member is -//! removed or renamed, and the minor component when a member or an optional -//! field is added. It is deliberately independent of the package version the -//! release workflow bumps, which tracks the shipped artifact. - -/// The wire contract version this crate defines. -pub const CONTRACT_VERSION: (u32, u32) = (1, 0); - -/// Returns whether a host holding [`CONTRACT_VERSION`] can bind to a module -/// reporting `module`. -/// -/// Compatibility is the ordinary semantic-version rule for a pre-release-free -/// contract: the majors must match, and the module must be at least as new as -/// the host, because a host cannot call a member a module does not serve. -/// -/// # Examples -/// -/// ``` -/// # use template_bus::{is_compatible, CONTRACT_VERSION}; -/// assert!(is_compatible(CONTRACT_VERSION)); -/// assert!(is_compatible((1, 4))); -/// assert!(!is_compatible((2, 0))); -/// ``` -#[must_use] -pub fn is_compatible(module: (u32, u32)) -> bool { - binds(CONTRACT_VERSION, module) -} - -/// The bind rule with the host version supplied explicitly. -/// -/// [`is_compatible`] is this function applied to [`CONTRACT_VERSION`]. It is -/// split out so the unit tests can exercise both directions of the comparison -/// without pinning them to whatever the shipped version happens to be. -fn binds(host: (u32, u32), module: (u32, u32)) -> bool { - let (host_major, host_minor) = host; - let (module_major, module_minor) = module; - - module_major == host_major && module_minor >= host_minor -} - -#[cfg(test)] -mod test; diff --git a/crates/template-bus/src/version/test.rs b/crates/template-bus/src/version/test.rs deleted file mode 100644 index 3fd3edf..0000000 --- a/crates/template-bus/src/version/test.rs +++ /dev/null @@ -1,34 +0,0 @@ -//! Unit tests for the contract version and its bind rule. - -use super::{CONTRACT_VERSION, binds, is_compatible}; - -#[test] -fn the_shipped_contract_version_is_pinned() { - assert_eq!(CONTRACT_VERSION, (1, 0)); -} - -#[test] -fn the_contract_binds_to_itself() { - assert!(is_compatible(CONTRACT_VERSION)); -} - -#[test] -fn a_newer_minor_on_the_module_side_binds() { - assert!(is_compatible((1, 1))); - assert!(is_compatible((1, 97))); -} - -#[test] -fn an_older_minor_on_the_module_side_is_rejected() { - // A host built against 1.4 cannot call a 1.2 module: the members it names - // may not be served. - assert!(!binds((1, 4), (1, 2))); - assert!(binds((1, 4), (1, 4))); -} - -#[test] -fn a_different_major_is_rejected() { - assert!(!is_compatible((0, 0))); - assert!(!is_compatible((2, 0))); - assert!(!is_compatible((2, 97))); -} diff --git a/crates/template/examples/verify_github_release.rs b/crates/template/examples/verify_github_release.rs deleted file mode 100644 index 9b173fe..0000000 --- a/crates/template/examples/verify_github_release.rs +++ /dev/null @@ -1,93 +0,0 @@ -//! Downloads a tagged release asset and calls the loaded `TinyBus` module. -//! -//! Run it with the release tag URL, platform archive, and archive SHA-256: -//! -//! ```text -//! cargo run --example verify_github_release -- \ -//! https://github.com/tinyhumansai/template/releases/tag/v0.1.4 \ -//! template-0.1.4-ubuntu-24.04-x86_64.tar.gz \ -//! -//! ``` - -use std::io; -use std::time::Duration; - -use template::{GreetRequest, GreetResponse, names}; -use tinybus::Connection; -use tinybus::broker::Broker; -use tinybus::module::ModuleHost; -use tinybus::transport::memory::MemoryBus; - -#[tokio::main] -async fn main() -> Result<(), Box> { - let (release_url, archive, sha256) = arguments()?; - let bus = MemoryBus::new(); - let broker = Broker::new(); - let broker_task = broker.spawn(bus.clone()); - let module_host = ModuleHost::new(broker); - let info = module_host.load_github_release( - &release_url, - &archive, - Some(&sha256), - serde_json::Value::default(), - )?; - - if info.name != env!("CARGO_PKG_NAME") { - return Err(io::Error::other(format!( - "loaded module `{}` instead of `{}`", - info.name, - env!("CARGO_PKG_NAME") - )) - .into()); - } - - let client = Connection::connect(bus.connect().await?).await?; - tokio::time::timeout(Duration::from_secs(5), async { - loop { - let claimed = client.list_names().await?; - if claimed.iter().any(|name| name.as_str() == names::INTERFACE) { - return tinybus::Result::Ok(()); - } - tokio::task::yield_now().await; - } - }) - .await??; - - let proxy = client.proxy(names::INTERFACE, names::OBJECT_PATH, names::INTERFACE)?; - let reply: GreetResponse = proxy - .call(names::methods::GREET, (GreetRequest::new("TinyBus"),)) - .await?; - if reply.greeting != "Hello, TinyBus!" { - return Err(io::Error::other(format!( - "module returned an unexpected greeting: {}", - reply.greeting - )) - .into()); - } - - println!( - "verified {archive} from {release_url} as TinyBus module `{}`", - info.name - ); - broker_task.abort(); - Ok(()) -} - -fn arguments() -> Result<(String, String, String), io::Error> { - let mut args = std::env::args().skip(1); - let usage = "usage: cargo run --example verify_github_release -- \ - "; - let release_url = args - .next() - .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, usage))?; - let archive = args - .next() - .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, usage))?; - let sha256 = args - .next() - .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, usage))?; - if args.next().is_some() { - return Err(io::Error::new(io::ErrorKind::InvalidInput, usage)); - } - Ok((release_url, archive, sha256)) -} diff --git a/crates/template/examples/verify_module.rs b/crates/template/examples/verify_module.rs deleted file mode 100644 index 6e3856e..0000000 --- a/crates/template/examples/verify_module.rs +++ /dev/null @@ -1,74 +0,0 @@ -//! Loads a built module through the real `TinyBus` dynamic loader. - -use std::io; -use std::path::PathBuf; -use std::time::Duration; - -use template::{GreetRequest, GreetResponse, names}; -use tinybus::Connection; -use tinybus::broker::Broker; -use tinybus::module::ModuleHost; -use tinybus::transport::memory::MemoryBus; - -#[tokio::main] -async fn main() -> Result<(), Box> { - let module = module_argument()?; - let bus = MemoryBus::new(); - let broker = Broker::new(); - let broker_task = broker.spawn(bus.clone()); - let module_host = ModuleHost::new(broker); - let info = module_host.load_file(&module)?; - - if info.name != env!("CARGO_PKG_NAME") { - return Err(io::Error::other(format!( - "loaded module `{}` instead of `{}`", - info.name, - env!("CARGO_PKG_NAME") - )) - .into()); - } - - let client = Connection::connect(bus.connect().await?).await?; - tokio::time::timeout(Duration::from_secs(5), async { - loop { - let claimed = client.list_names().await?; - if claimed.iter().any(|name| name.as_str() == names::INTERFACE) { - return tinybus::Result::Ok(()); - } - tokio::task::yield_now().await; - } - }) - .await??; - - let proxy = client.proxy(names::INTERFACE, names::OBJECT_PATH, names::INTERFACE)?; - let reply: GreetResponse = proxy - .call(names::methods::GREET, (GreetRequest::new("TinyBus"),)) - .await?; - if reply.greeting != "Hello, TinyBus!" { - return Err(io::Error::other(format!( - "module returned an unexpected greeting: {}", - reply.greeting - )) - .into()); - } - - println!( - "verified {} as TinyBus module `{}`", - module.display(), - info.name - ); - broker_task.abort(); - Ok(()) -} - -fn module_argument() -> Result { - std::env::args_os() - .nth(1) - .map(PathBuf::from) - .ok_or_else(|| { - io::Error::new( - io::ErrorKind::InvalidInput, - "usage: cargo run --example verify_module -- ", - ) - }) -} diff --git a/crates/template/src/error/test.rs b/crates/template/src/error/test.rs deleted file mode 100644 index 4c5d609..0000000 --- a/crates/template/src/error/test.rs +++ /dev/null @@ -1,17 +0,0 @@ -//! Unit tests for the crate-wide error type. - -#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] - -use super::*; - -#[test] -fn renders_a_human_readable_message() { - assert_eq!(Error::EmptyName.to_string(), "name must not be empty"); -} - -#[test] -fn is_a_standard_error() { - fn assert_error(_: &E) {} - - assert_error(&Error::EmptyName); -} diff --git a/crates/template/src/greeting/mod.rs b/crates/template/src/greeting/mod.rs deleted file mode 100644 index 862fa21..0000000 --- a/crates/template/src/greeting/mod.rs +++ /dev/null @@ -1,38 +0,0 @@ -//! Greeting behavior used to demonstrate the template's module layout. -//! -//! A module root like this one documents the module, wires its pieces -//! together, and exposes the smallest useful API. Substantial type definitions -//! belong in a sibling `types.rs`, and unit tests belong in `test.rs`, wired in -//! at the bottom of this file. -//! -//! Replace this module with the crate's first real feature area. - -use crate::{Error, Result}; - -/// Returns a friendly greeting for `name`. -/// -/// Surrounding whitespace is trimmed before the greeting is built. -/// -/// # Examples -/// -/// ``` -/// # use template::greet; -/// assert_eq!(greet(" Ferris ")?, "Hello, Ferris!"); -/// # Ok::<(), template::Error>(()) -/// ``` -/// -/// # Errors -/// -/// Returns [`Error::EmptyName`] when `name` is empty or contains only -/// whitespace. -pub fn greet(name: &str) -> Result { - let name = name.trim(); - if name.is_empty() { - return Err(Error::EmptyName); - } - - Ok(format!("Hello, {name}!")) -} - -#[cfg(test)] -mod test; diff --git a/crates/template/src/greeting/test.rs b/crates/template/src/greeting/test.rs deleted file mode 100644 index de04ef4..0000000 --- a/crates/template/src/greeting/test.rs +++ /dev/null @@ -1,28 +0,0 @@ -//! Unit tests for the greeting module. -//! -//! Unit tests live next to the code they cover and may reach into private -//! items. Tests of the public contract belong in `tests/` instead. - -#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] - -use super::*; - -#[test] -fn greets_a_named_person() { - assert_eq!(greet("Ferris").unwrap(), "Hello, Ferris!"); -} - -#[test] -fn trims_the_name() { - assert_eq!(greet(" Ferris ").unwrap(), "Hello, Ferris!"); -} - -#[test] -fn rejects_an_empty_name() { - assert_eq!(greet("").unwrap_err(), Error::EmptyName); -} - -#[test] -fn rejects_a_whitespace_only_name() { - assert_eq!(greet(" \t\n ").unwrap_err(), Error::EmptyName); -} diff --git a/crates/template/src/tinybus_module/README.md b/crates/template/src/tinybus_module/README.md deleted file mode 100644 index 2c05772..0000000 --- a/crates/template/src/tinybus_module/README.md +++ /dev/null @@ -1,20 +0,0 @@ -# TinyBus Adapter - -This module is the boundary between ordinary feature code and TinyBus module -ABI v1. `GreetingService` converts the crate's public `greet` function into the typed -`Greet` bus method, while `setup` registers its object and claims the well-known -interface name. Neither the name, the object path, nor the payload types are -spelled here: they come from `template-bus`, so a rename is a compile error in -every consumer instead of an `UnknownMethod` at runtime. - -`tinybus_module::module_export!` emits the descriptor, embedded manifest, and -initialization symbols consumed by the dynamic loader. The manifest method list -must stay aligned with the interface macro's dispatch table and with -`template_bus::names::METHODS`; the unit tests check both relationships. -Integration tests use TinyBus's in-memory transport, and -`crates/template/examples/verify_module.rs` loads a compiled `cdylib` through -the real dynamic loader before a release archive is accepted. - -Generated projects should replace the example interface, object path, and method -declarations together — here and in `crates/template-bus/src/names/`. They must not retain Rust-owned data across the -ABI boundary or bypass the SDK exports with an ad hoc FFI surface. diff --git a/crates/template/src/tinybus_module/mod.rs b/crates/template/src/tinybus_module/mod.rs deleted file mode 100644 index 1c9c2f0..0000000 --- a/crates/template/src/tinybus_module/mod.rs +++ /dev/null @@ -1,43 +0,0 @@ -//! `TinyBus` module entrypoint and bus-facing interface. -//! -//! This adapter keeps the feature implementation independent from `TinyBus` -//! while exposing it as an installable, dynamically loaded integration. The -//! names and payload types it serves come from [`template_bus`], so a host -//! spells them from the contract crate instead of repeating string literals. - -use template_bus::{GreetRequest, GreetResponse, names}; -use tinybus::{Connection, Result as TinyBusResult}; - -struct GreetingService; - -#[tinybus::interface(name = "ai.tinyhumans.template.Greeting")] -impl GreetingService { - async fn greet(&self, request: GreetRequest) -> TinyBusResult { - std::future::ready(crate::greet(&request.name)) - .await - .map(GreetResponse::new) - .map_err(|error| tinybus::Error::failed(error.to_string())) - } -} - -async fn setup(connection: Connection) -> TinyBusResult<()> { - connection - .serve_at(names::OBJECT_PATH.try_into()?, GreetingService) - .await?; - connection.request_name(names::INTERFACE).await?; - Ok(()) -} - -tinybus_module::module_export! { - setup = setup, - worker_threads = 1, - provides = ["ai.tinyhumans.template.Greeting"], - methods = ["Greet"], - signals = [], - requires = [], - optional = [], - lazy = false, -} - -#[cfg(test)] -mod test; diff --git a/crates/template/src/tinybus_module/test.rs b/crates/template/src/tinybus_module/test.rs deleted file mode 100644 index d5fe71a..0000000 --- a/crates/template/src/tinybus_module/test.rs +++ /dev/null @@ -1,64 +0,0 @@ -//! Tests for the `TinyBus` module adapter and its declared surface. - -use super::{GreetingService, setup}; -use template_bus::{GreetRequest, GreetResponse, names}; -use tinybus::broker::Broker; -use tinybus::transport::memory::MemoryBus; -use tinybus::{Connection, Interface}; - -#[test] -fn declared_methods_match_the_dispatch_table() { - let methods = GreetingService - .members() - .into_iter() - .map(|member| member.to_string()) - .collect::>(); - - assert_eq!(methods, names::METHODS.to_vec()); -} - -#[test] -fn the_served_interface_name_matches_the_contract() { - assert_eq!(GreetingService.name().to_string(), names::INTERFACE); -} - -#[tokio::test] -async fn module_serves_greetings_over_a_real_bus() -> tinybus::Result<()> { - let bus = MemoryBus::new(); - Broker::new().spawn(bus.clone()); - - let service = Connection::connect(bus.connect().await?).await?; - setup(service.clone()).await?; - - let client = Connection::connect(bus.connect().await?).await?; - let proxy = client.proxy(names::INTERFACE, names::OBJECT_PATH, names::INTERFACE)?; - let reply: GreetResponse = proxy - .call(names::methods::GREET, (GreetRequest::new("Ferris"),)) - .await?; - - assert_eq!(reply, GreetResponse::new("Hello, Ferris!")); - Ok(()) -} - -#[tokio::test] -async fn module_rejects_an_empty_name_over_the_bus() -> tinybus::Result<()> { - let bus = MemoryBus::new(); - Broker::new().spawn(bus.clone()); - - let service = Connection::connect(bus.connect().await?).await?; - setup(service.clone()).await?; - - let client = Connection::connect(bus.connect().await?).await?; - let proxy = client.proxy(names::INTERFACE, names::OBJECT_PATH, names::INTERFACE)?; - let result = proxy - .call::(names::methods::GREET, (GreetRequest::new(" "),)) - .await; - - let Err(error) = result else { - return Err(tinybus::Error::failed( - "whitespace-only names unexpectedly succeeded", - )); - }; - assert!(error.to_string().contains("name must not be empty")); - Ok(()) -} diff --git a/crates/tinyjevclient/Cargo.toml b/crates/tinyjevclient/Cargo.toml new file mode 100644 index 0000000..5de57b6 --- /dev/null +++ b/crates/tinyjevclient/Cargo.toml @@ -0,0 +1,29 @@ +[package] +name = "tinyjevclient" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +description = "A typed Rust client for TypeSafe AI's System One API and Jev model." +documentation = "https://docs.rs/tinyjevclient" +readme = "../../README.md" +keywords = ["ai", "classification", "jev", "typesafe"] +categories = ["api-bindings", "web-programming::http-client"] +publish = false + +[dependencies] +reqwest = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +thiserror = { workspace = true } +tokio = { workspace = true } + +[dev-dependencies] +tokio = { workspace = true, features = ["io-util", "macros", "net", "rt-multi-thread", "sync"] } + +[features] +default = [] + +[lints] +workspace = true diff --git a/crates/tinyjevclient/examples/basic.rs b/crates/tinyjevclient/examples/basic.rs new file mode 100644 index 0000000..ac228b8 --- /dev/null +++ b/crates/tinyjevclient/examples/basic.rs @@ -0,0 +1,27 @@ +//! Evaluate one ticket when `TYPESAFE_API_KEY` is configured. + +use std::collections::BTreeMap; + +use serde_json::json; +use tinyjevclient::{Choice, Client, EvaluationRequest, Question}; + +#[tokio::main] +async fn main() -> tinyjevclient::Result<()> { + let request = EvaluationRequest::jev( + json!({"ticket": "I was charged twice. Please fix this."}), + BTreeMap::from([( + "route".to_owned(), + Question::Choice(Choice { + instructions: json!("Which team should handle this ticket?"), + criteria: BTreeMap::from([ + ("billing".to_owned(), Some(json!("payments and refunds"))), + ("technical".to_owned(), Some(json!("bugs and outages"))), + ("other".to_owned(), None), + ]), + }), + )]), + ); + let result = Client::from_env()?.evaluate(&request).await?; + println!("{:?}", result.response.answers["route"]); + Ok(()) +} diff --git a/crates/tinyjevclient/src/README.md b/crates/tinyjevclient/src/README.md new file mode 100644 index 0000000..f314dda --- /dev/null +++ b/crates/tinyjevclient/src/README.md @@ -0,0 +1,8 @@ +# Source layout + +| directory | responsibility | +| --- | --- | +| `client/` | HTTP execution, bounded retry, secret handling, and measurements | +| `request/` | typed Choice, Score, and Noul request payloads and validation | +| `response/` | typed answers and request-relative response validation | +| `error/` | crate-wide classified failures | diff --git a/crates/tinyjevclient/src/client/README.md b/crates/tinyjevclient/src/client/README.md new file mode 100644 index 0000000..4bf3dfc --- /dev/null +++ b/crates/tinyjevclient/src/client/README.md @@ -0,0 +1,6 @@ +# Client module + +The client validates a request, sends it to the System One endpoint, classifies +HTTP failures, retries only transient failures, validates the response against +the original questions, and returns attempts and end-to-end latency. API keys +remain private and render only as `[REDACTED]`. diff --git a/crates/tinyjevclient/src/client/mod.rs b/crates/tinyjevclient/src/client/mod.rs new file mode 100644 index 0000000..131c909 --- /dev/null +++ b/crates/tinyjevclient/src/client/mod.rs @@ -0,0 +1,199 @@ +//! Async HTTP client, retry policy, and measured evaluation result. + +#[cfg(test)] +mod test; + +mod types; + +pub use types::{Client, ClientConfig, EvaluationResult, RetryPolicy}; + +use std::time::{Duration, Instant}; + +use reqwest::{StatusCode, header::RETRY_AFTER}; + +use crate::{Error, EvaluationRequest, EvaluationResponse, Result}; + +const SYSTEM_ONE_PATH: &str = "v1/systemone"; + +impl Client { + /// Construct a client from an explicit configuration. + /// + /// # Errors + /// + /// Returns [`Error::InvalidConfig`] for an empty key, invalid base URL, + /// zero timeout, or invalid retry policy. + pub fn new(config: ClientConfig) -> Result { + config.validate()?; + let http = reqwest::Client::builder() + .timeout(config.timeout) + .build() + .map_err(|source| Error::Transport { source })?; + Ok(Self { config, http }) + } + + /// Construct a client using `TYPESAFE_API_KEY` and production defaults. + /// + /// # Errors + /// + /// Returns [`Error::MissingApiKey`] when the variable is absent, or the + /// same configuration errors as [`Self::new`]. + pub fn from_env() -> Result { + let api_key = std::env::var("TYPESAFE_API_KEY").map_err(|_| Error::MissingApiKey)?; + Self::new(ClientConfig::new(api_key)) + } + + /// Evaluate typed questions against shared state. + /// + /// The returned latency includes retry delays and all attempts. Request and + /// response bodies are never logged by this crate. + /// + /// # Errors + /// + /// Returns request validation, transport, HTTP, decoding, or response + /// contract errors. Only transient transport failures, rate limits, and + /// overload responses are retried. + pub async fn evaluate(&self, request: &EvaluationRequest) -> Result { + request.validate()?; + let started = Instant::now(); + let mut attempts = 0_u32; + loop { + attempts = attempts.saturating_add(1); + match self.send_once(request).await { + Ok((response, request_id)) => { + response.validate_for(request)?; + return Ok(EvaluationResult { + response, + request_id, + attempts, + latency: started.elapsed(), + }); + } + Err(Failure::Terminal(error)) => return Err(error), + Err(Failure::Retryable { error, retry_after }) => { + if attempts > self.config.retry.max_retries { + return Err(error); + } + let delay = retry_after.unwrap_or_else(|| self.config.retry.delay(attempts)); + tokio::time::sleep(delay.min(self.config.retry.max_backoff)).await; + } + } + } + } + + async fn send_once( + &self, + request: &EvaluationRequest, + ) -> std::result::Result<(EvaluationResponse, Option), Failure> { + let url = format!( + "{}/{}", + self.config.base_url.trim_end_matches('/'), + SYSTEM_ONE_PATH + ); + let response = self + .http + .post(url) + .bearer_auth(self.config.api_key.expose()) + .json(request) + .send() + .await + .map_err(classify_transport)?; + let request_id = response + .headers() + .get("x-request-id") + .and_then(|value| value.to_str().ok()) + .map(str::to_owned); + let status = response.status(); + if !status.is_success() { + let retry_after = parse_retry_after(response.headers().get(RETRY_AFTER)); + return Err(classify_status(status, retry_after)); + } + let bytes = response.bytes().await.map_err(classify_transport)?; + let decoded = serde_json::from_slice(&bytes) + .map_err(|source| Failure::Terminal(Error::Decode { source }))?; + Ok((decoded, request_id)) + } +} + +impl ClientConfig { + fn validate(&self) -> Result<()> { + if self.api_key.expose().trim().is_empty() { + return Err(Error::InvalidConfig { + reason: "api key must not be empty".to_owned(), + }); + } + let url = reqwest::Url::parse(&self.base_url).map_err(|_| Error::InvalidConfig { + reason: "base URL must be an absolute HTTP URL".to_owned(), + })?; + if !matches!(url.scheme(), "http" | "https") { + return Err(Error::InvalidConfig { + reason: "base URL must use HTTP or HTTPS".to_owned(), + }); + } + if self.timeout.is_zero() { + return Err(Error::InvalidConfig { + reason: "timeout must be greater than zero".to_owned(), + }); + } + if self.retry.initial_backoff.is_zero() || self.retry.max_backoff.is_zero() { + return Err(Error::InvalidConfig { + reason: "retry backoffs must be greater than zero".to_owned(), + }); + } + Ok(()) + } +} + +enum Failure { + Terminal(Error), + Retryable { + error: Error, + retry_after: Option, + }, +} + +fn classify_transport(source: reqwest::Error) -> Failure { + if source.is_timeout() { + Failure::Retryable { + error: Error::Timeout, + retry_after: None, + } + } else { + Failure::Retryable { + error: Error::Transport { source }, + retry_after: None, + } + } +} + +fn classify_status(status: StatusCode, retry_after: Option) -> Failure { + match status { + StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN => { + Failure::Terminal(Error::Authentication) + } + StatusCode::UNPROCESSABLE_ENTITY | StatusCode::BAD_REQUEST => { + Failure::Terminal(Error::Unprocessable) + } + StatusCode::TOO_MANY_REQUESTS => Failure::Retryable { + error: Error::RateLimited, + retry_after, + }, + status if status.as_u16() == 529 => Failure::Retryable { + error: Error::Overloaded, + retry_after, + }, + status if status.is_server_error() => Failure::Retryable { + error: Error::HttpStatus { + status: status.as_u16(), + }, + retry_after, + }, + status => Failure::Terminal(Error::HttpStatus { + status: status.as_u16(), + }), + } +} + +fn parse_retry_after(value: Option<&reqwest::header::HeaderValue>) -> Option { + let seconds = value?.to_str().ok()?.parse::().ok()?; + Some(Duration::from_secs(seconds)) +} diff --git a/crates/tinyjevclient/src/client/test.rs b/crates/tinyjevclient/src/client/test.rs new file mode 100644 index 0000000..186e106 --- /dev/null +++ b/crates/tinyjevclient/src/client/test.rs @@ -0,0 +1,164 @@ +//! Client transport, retry, measurement, and secret-handling tests. + +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use std::{collections::BTreeMap, sync::Arc, time::Duration}; + +use serde_json::json; +use tokio::{ + io::{AsyncReadExt, AsyncWriteExt}, + net::TcpListener, + sync::Mutex, +}; + +use super::*; +use crate::{Choice, Question}; + +fn request() -> EvaluationRequest { + EvaluationRequest::jev( + json!({"message": "review this"}), + BTreeMap::from([( + "route".to_owned(), + Question::Choice(Choice { + instructions: json!("Who should answer?"), + criteria: BTreeMap::from([("alice".to_owned(), None), ("bob".to_owned(), None)]), + }), + )]), + ) +} + +fn success() -> String { + json!({ + "model": "jev-latest", + "answers": { + "route": { + "type": "choice", + "choice": "bob", + "probabilities": {"alice": 0.2, "bob": 0.8}, + "confidence": 0.6 + } + }, + "usage": {"input_tokens": 12, "output_tokens": 2} + }) + .to_string() +} + +fn response(status: u16, body: &str, extra_headers: &str) -> String { + let reason = if status == 200 { "OK" } else { "Error" }; + format!( + "HTTP/1.1 {status} {reason}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n{extra_headers}\r\n{body}", + body.len() + ) +} + +async fn server(responses: Vec) -> (String, Arc>>) { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let requests = Arc::new(Mutex::new(Vec::new())); + let recorded = Arc::clone(&requests); + tokio::spawn(async move { + for reply in responses { + let (mut stream, _) = listener.accept().await.unwrap(); + let mut buffer = vec![0_u8; 16_384]; + let count = stream.read(&mut buffer).await.unwrap(); + recorded + .lock() + .await + .push(String::from_utf8_lossy(&buffer[..count]).into_owned()); + stream.write_all(reply.as_bytes()).await.unwrap(); + } + }); + (format!("http://{address}"), requests) +} + +fn config(base_url: String) -> ClientConfig { + let mut config = ClientConfig::new("secret-test-key"); + config.base_url = base_url; + config.timeout = Duration::from_secs(1); + config.retry = RetryPolicy { + max_retries: 0, + initial_backoff: Duration::from_millis(1), + max_backoff: Duration::from_millis(5), + }; + config +} + +#[tokio::test] +async fn sends_the_documented_endpoint_and_bearer_header() { + let (base_url, requests) = server(vec![response( + 200, + &success(), + "x-request-id: request-7\r\n", + )]) + .await; + let result = Client::new(config(base_url)) + .unwrap() + .evaluate(&request()) + .await + .unwrap(); + assert_eq!(result.attempts, 1); + assert_eq!(result.request_id.as_deref(), Some("request-7")); + assert_eq!(result.response.usage.input_tokens, Some(12)); + let sent = requests.lock().await.join(""); + assert!(sent.starts_with("POST /v1/systemone HTTP/1.1")); + assert!( + sent.to_ascii_lowercase() + .contains("authorization: bearer secret-test-key") + ); + assert!(sent.contains("\"model\":\"jev-latest\"")); +} + +#[tokio::test] +async fn retries_rate_limits_and_reports_attempts() { + let (base_url, requests) = + server(vec![response(429, "{}", ""), response(200, &success(), "")]).await; + let mut config = config(base_url); + config.retry.max_retries = 1; + let result = Client::new(config) + .unwrap() + .evaluate(&request()) + .await + .unwrap(); + assert_eq!(result.attempts, 2); + assert_eq!(requests.lock().await.len(), 2); +} + +#[tokio::test] +async fn authentication_is_terminal() { + let (base_url, requests) = server(vec![response(401, "{}", "")]).await; + let error = Client::new(config(base_url)) + .unwrap() + .evaluate(&request()) + .await + .unwrap_err(); + assert!(matches!(error, Error::Authentication)); + assert_eq!(requests.lock().await.len(), 1); +} + +#[tokio::test] +async fn malformed_success_body_is_a_decode_failure() { + let (base_url, _) = server(vec![response(200, "not-json", "")]).await; + let error = Client::new(config(base_url)) + .unwrap() + .evaluate(&request()) + .await + .unwrap_err(); + assert!(matches!(error, Error::Decode { .. })); +} + +#[tokio::test] +async fn debug_output_redacts_the_api_key() { + let rendered = format!("{:?}", config("http://127.0.0.1:1".to_owned())); + assert!(rendered.contains("[REDACTED]")); + assert!(!rendered.contains("secret-test-key")); +} + +#[test] +fn rejects_invalid_configuration_before_transport() { + let mut empty = ClientConfig::new(""); + empty.base_url = "not a URL".to_owned(); + assert!(matches!( + Client::new(empty), + Err(Error::InvalidConfig { .. }) + )); +} diff --git a/crates/tinyjevclient/src/client/types.rs b/crates/tinyjevclient/src/client/types.rs new file mode 100644 index 0000000..b7c3e1f --- /dev/null +++ b/crates/tinyjevclient/src/client/types.rs @@ -0,0 +1,115 @@ +//! Client configuration and measured result types. + +use std::{fmt, time::Duration}; + +use crate::EvaluationResponse; + +/// Async `TypeSafe` System One client. +#[derive(Clone)] +pub struct Client { + pub(super) config: ClientConfig, + pub(super) http: reqwest::Client, +} + +impl fmt::Debug for Client { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("Client") + .field("config", &self.config) + .finish_non_exhaustive() + } +} + +/// HTTP client configuration. +#[derive(Clone)] +pub struct ClientConfig { + pub(super) api_key: ApiKey, + /// API root without the versioned endpoint path. + pub base_url: String, + /// Total timeout for one HTTP attempt. + pub timeout: Duration, + /// Transient failure retry policy. + pub retry: RetryPolicy, +} + +impl ClientConfig { + /// Create production configuration for an API key. + #[must_use] + pub fn new(api_key: impl Into) -> Self { + Self { + api_key: ApiKey(api_key.into()), + base_url: "https://api.typesafe.ai".to_owned(), + timeout: Duration::from_secs(30), + retry: RetryPolicy::default(), + } + } + + /// Replace the API key without exposing it through a public field. + #[must_use] + pub fn with_api_key(mut self, api_key: impl Into) -> Self { + self.api_key = ApiKey(api_key.into()); + self + } +} + +impl fmt::Debug for ClientConfig { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("ClientConfig") + .field("api_key", &"[REDACTED]") + .field("base_url", &self.base_url) + .field("timeout", &self.timeout) + .field("retry", &self.retry) + .finish() + } +} + +#[derive(Clone)] +pub(super) struct ApiKey(String); + +impl ApiKey { + pub(super) fn expose(&self) -> &str { + &self.0 + } +} + +/// Retry limits for transient failures. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct RetryPolicy { + /// Additional attempts after the initial request. + pub max_retries: u32, + /// Delay used after the first retryable failure. + pub initial_backoff: Duration, + /// Maximum delay, including provider-requested delays. + pub max_backoff: Duration, +} + +impl RetryPolicy { + pub(super) fn delay(self, attempts: u32) -> Duration { + let exponent = attempts.saturating_sub(1).min(31); + self.initial_backoff + .saturating_mul(1_u32.checked_shl(exponent).unwrap_or(u32::MAX)) + .min(self.max_backoff) + } +} + +impl Default for RetryPolicy { + fn default() -> Self { + Self { + max_retries: 2, + initial_backoff: Duration::from_millis(100), + max_backoff: Duration::from_secs(2), + } + } +} + +/// A validated response with transport measurements. +#[derive(Clone, Debug, PartialEq)] +pub struct EvaluationResult { + /// Typed, validated provider response. + pub response: EvaluationResponse, + /// Provider request id, when returned as a header. + pub request_id: Option, + /// HTTP attempts including the successful or terminal attempt. + pub attempts: u32, + /// End-to-end elapsed time including retry delays. + pub latency: Duration, +} diff --git a/crates/tinyjevclient/src/error/README.md b/crates/tinyjevclient/src/error/README.md new file mode 100644 index 0000000..a8cc335 --- /dev/null +++ b/crates/tinyjevclient/src/error/README.md @@ -0,0 +1,5 @@ +# Error module + +The crate-wide `Error` enum classifies configuration, request, transport, HTTP, +decoding, and response-contract failures without retaining credentials or response +bodies. `test.rs` pins safe rendering. diff --git a/crates/tinyjevclient/src/error/mod.rs b/crates/tinyjevclient/src/error/mod.rs new file mode 100644 index 0000000..edbcc77 --- /dev/null +++ b/crates/tinyjevclient/src/error/mod.rs @@ -0,0 +1,86 @@ +//! Crate-wide error and result types. + +/// Errors returned by this crate. +#[derive(Debug, thiserror::Error)] +#[non_exhaustive] +pub enum Error { + /// No API key was configured. + #[error("TYPESAFE_API_KEY must be set or supplied explicitly")] + MissingApiKey, + /// Client configuration is not usable. + #[error("invalid client configuration: {reason}")] + InvalidConfig { + /// Stable explanation of the rejected field. + reason: String, + }, + /// The request violates the local System One contract. + #[error("invalid request: {reason}")] + InvalidRequest { + /// Stable explanation of the rejected value. + reason: String, + }, + /// Authentication was rejected. + #[error("TypeSafe authentication failed")] + Authentication, + /// The provider rejected the request shape. + #[error("TypeSafe rejected the request")] + Unprocessable, + /// The account or endpoint rate limit was reached. + #[error("TypeSafe rate limit exceeded")] + RateLimited, + /// The `TypeSafe` service reported temporary overload. + #[error("TypeSafe service overloaded")] + Overloaded, + /// The endpoint returned another unsuccessful status. + #[error("TypeSafe request failed with status {status}")] + HttpStatus { + /// Returned HTTP status code. + status: u16, + }, + /// The request timed out. + #[error("TypeSafe request timed out")] + Timeout, + /// The HTTP transport failed before a response was available. + #[error("TypeSafe transport failed")] + Transport { + /// Underlying transport failure. + #[source] + source: reqwest::Error, + }, + /// The response body was not valid JSON for the declared wire shape. + #[error("TypeSafe response could not be decoded")] + Decode { + /// Underlying JSON decoding failure. + #[source] + source: serde_json::Error, + }, + /// The decoded response is inconsistent with the request. + #[error("invalid response: {reason}")] + InvalidResponse { + /// Stable explanation of the contract violation. + reason: String, + }, +} + +impl Error { + pub(crate) fn invalid_request(reason: impl Into) -> Self { + Self::InvalidRequest { + reason: reason.into(), + } + } + + pub(crate) fn invalid_response(reason: impl Into) -> Self { + Self::InvalidResponse { + reason: reason.into(), + } + } +} + +/// The crate's standard result type. +/// +/// Use this alias in public signatures instead of spelling out +/// `std::result::Result`. +pub type Result = std::result::Result; + +#[cfg(test)] +mod test; diff --git a/crates/tinyjevclient/src/error/test.rs b/crates/tinyjevclient/src/error/test.rs new file mode 100644 index 0000000..9582bbc --- /dev/null +++ b/crates/tinyjevclient/src/error/test.rs @@ -0,0 +1,33 @@ +//! Error rendering behavior. + +use super::*; + +#[test] +fn messages_do_not_render_secrets_or_response_bodies() { + let errors = [ + Error::MissingApiKey, + Error::Authentication, + Error::Unprocessable, + Error::RateLimited, + Error::Overloaded, + Error::HttpStatus { status: 503 }, + Error::Timeout, + ]; + for error in errors { + let rendered = error.to_string(); + assert!(!rendered.contains("Bearer")); + assert!(!rendered.contains("apikey_")); + } +} + +#[test] +fn contextual_errors_name_their_category() { + assert_eq!( + Error::invalid_request("questions must not be empty").to_string(), + "invalid request: questions must not be empty" + ); + assert_eq!( + Error::invalid_response("answer missing").to_string(), + "invalid response: answer missing" + ); +} diff --git a/crates/tinyjevclient/src/lib.rs b/crates/tinyjevclient/src/lib.rs new file mode 100644 index 0000000..3040ac8 --- /dev/null +++ b/crates/tinyjevclient/src/lib.rs @@ -0,0 +1,47 @@ +//! Typed Rust access to `TypeSafe` AI's System One API and Jev model. +//! +//! A request supplies text or structured state plus independent [`Question`]s. +//! Jev returns typed choices, ordinal scores, and yes/no probabilities for code +//! to compose. This crate validates both sides of that wire contract and owns +//! only the HTTP wait; policy, thresholds, and actions stay with the caller. +//! +//! # Example +//! +//! ```no_run +//! use std::collections::BTreeMap; +//! use serde_json::json; +//! use tinyjevclient::{Choice, Client, EvaluationRequest, Question}; +//! +//! # async fn example() -> tinyjevclient::Result<()> { +//! let criteria = BTreeMap::from([ +//! ("billing".to_owned(), Some(json!("payments and refunds"))), +//! ("technical".to_owned(), Some(json!("bugs and outages"))), +//! ]); +//! let request = EvaluationRequest::jev( +//! json!({"ticket": "I was charged twice"}), +//! BTreeMap::from([( +//! "route".to_owned(), +//! Question::Choice(Choice { +//! instructions: json!("Which team should handle this ticket?"), +//! criteria, +//! }), +//! )]), +//! ); +//! let result = Client::from_env()?.evaluate(&request).await?; +//! println!("{:?}", result.response.answers["route"]); +//! # Ok(()) +//! # } +//! ``` +//! +//! The crate deliberately does not execute a selected action, infer permission +//! from confidence, or hide a retry behind an unbounded loop. + +mod client; +mod error; +mod request; +mod response; + +pub use client::{Client, ClientConfig, EvaluationResult, RetryPolicy}; +pub use error::{Error, Result}; +pub use request::{Choice, EvaluationRequest, Noul, NoulCriteria, Question, Score}; +pub use response::{Answer, ChoiceAnswer, EvaluationResponse, NoulAnswer, ScoreAnswer, Usage}; diff --git a/crates/tinyjevclient/src/request/README.md b/crates/tinyjevclient/src/request/README.md new file mode 100644 index 0000000..433c4a8 --- /dev/null +++ b/crates/tinyjevclient/src/request/README.md @@ -0,0 +1,5 @@ +# Request module + +This module defines the exact state-and-questions payload sent to TypeSafe's +System One endpoint. `types.rs` holds the serde wire types, `mod.rs` validates +the documented primitive bounds before transport, and `test.rs` pins both. diff --git a/crates/tinyjevclient/src/request/mod.rs b/crates/tinyjevclient/src/request/mod.rs new file mode 100644 index 0000000..a035582 --- /dev/null +++ b/crates/tinyjevclient/src/request/mod.rs @@ -0,0 +1,119 @@ +//! Typed System One request values and their local validation. + +#[cfg(test)] +mod test; + +mod types; + +pub use types::{Choice, EvaluationRequest, Noul, NoulCriteria, Question, Score}; + +use crate::{Error, Result}; + +impl EvaluationRequest { + /// Validate this request before any network operation begins. + /// + /// # Errors + /// + /// Returns [`Error::InvalidRequest`] when the model, state, question ids, + /// instructions, or criteria cannot form a valid System One request. + pub fn validate(&self) -> Result<()> { + if self.model.trim().is_empty() { + return Err(Error::invalid_request("model must not be empty")); + } + if !matches!( + self.state, + serde_json::Value::String(_) + | serde_json::Value::Array(_) + | serde_json::Value::Object(_) + ) { + return Err(Error::invalid_request( + "state must be a string, object, or array", + )); + } + if self.questions.is_empty() { + return Err(Error::invalid_request("questions must not be empty")); + } + for (id, question) in &self.questions { + if id.trim().is_empty() { + return Err(Error::invalid_request("question ids must not be empty")); + } + question.validate()?; + } + Ok(()) + } +} + +impl Question { + fn validate(&self) -> Result<()> { + match self { + Self::Choice(question) => question.validate(), + Self::Score(question) => question.validate(), + Self::Noul(question) => question.validate(), + } + } +} + +impl Choice { + fn validate(&self) -> Result<()> { + validate_instructions(&self.instructions)?; + if !(2..=255).contains(&self.criteria.len()) { + return Err(Error::invalid_request( + "choice criteria must contain between 2 and 255 options", + )); + } + if self.criteria.keys().any(|key| key.trim().is_empty()) { + return Err(Error::invalid_request( + "choice option names must not be empty", + )); + } + Ok(()) + } +} + +impl Score { + fn validate(&self) -> Result<()> { + validate_instructions(&self.instructions)?; + if !(2..=10).contains(&self.criteria.len()) { + return Err(Error::invalid_request( + "score criteria must contain between 2 and 10 levels", + )); + } + if self.criteria.iter().any(is_empty_text) { + return Err(Error::invalid_request( + "score level descriptions must not be empty", + )); + } + Ok(()) + } +} + +impl Noul { + fn validate(&self) -> Result<()> { + validate_instructions(&self.instructions)?; + if let Some(criteria) = &self.criteria + && (is_empty_text(&criteria.r#true) || is_empty_text(&criteria.r#false)) + { + return Err(Error::invalid_request( + "noul criteria descriptions must not be empty", + )); + } + Ok(()) + } +} + +fn validate_instructions(value: &serde_json::Value) -> Result<()> { + if !matches!( + value, + serde_json::Value::String(_) | serde_json::Value::Array(_) | serde_json::Value::Object(_) + ) || is_empty_text(value) + { + return Err(Error::invalid_request( + "instructions must be a nonempty string, object, or array", + )); + } + Ok(()) +} + +fn is_empty_text(value: &serde_json::Value) -> bool { + matches!(value, serde_json::Value::String(text) if text.trim().is_empty()) +} diff --git a/crates/tinyjevclient/src/request/test.rs b/crates/tinyjevclient/src/request/test.rs new file mode 100644 index 0000000..371dcb3 --- /dev/null +++ b/crates/tinyjevclient/src/request/test.rs @@ -0,0 +1,120 @@ +//! Request wire and validation tests. + +#![allow(clippy::unwrap_used)] + +use std::collections::BTreeMap; + +use serde_json::json; + +use super::*; + +fn questions() -> BTreeMap { + BTreeMap::from([ + ( + "route".to_owned(), + Question::Choice(Choice { + instructions: json!("Which agent should answer?"), + criteria: BTreeMap::from([ + ("planner".to_owned(), Some(json!("plans work"))), + ("reviewer".to_owned(), None), + ]), + }), + ), + ( + "quality".to_owned(), + Question::Score(Score { + instructions: json!("How strong is the evidence?"), + criteria: vec![json!("unsupported"), json!("direct")], + }), + ), + ( + "unsafe".to_owned(), + Question::Noul(Noul { + instructions: json!("Does this violate a stated constraint?"), + criteria: Some(NoulCriteria { + r#true: json!("a constraint is violated"), + r#false: json!("all constraints are satisfied"), + }), + }), + ), + ]) +} + +#[test] +fn every_primitive_pins_its_wire_shape() { + let request = EvaluationRequest::jev(json!({"message": "review this"}), questions()); + assert_eq!( + serde_json::to_value(request).unwrap(), + json!({ + "state": {"message": "review this"}, + "model": "jev-latest", + "questions": { + "quality": { + "type": "score", + "instructions": "How strong is the evidence?", + "criteria": ["unsupported", "direct"] + }, + "route": { + "type": "choice", + "instructions": "Which agent should answer?", + "criteria": {"planner": "plans work", "reviewer": null} + }, + "unsafe": { + "type": "noul", + "instructions": "Does this violate a stated constraint?", + "criteria": { + "true": "a constraint is violated", + "false": "all constraints are satisfied" + } + } + } + }) + ); +} + +#[test] +fn accepts_string_object_and_array_state() { + for state in [json!("text"), json!({"field": true}), json!([1, 2])] { + EvaluationRequest::jev(state, questions()) + .validate() + .unwrap(); + } +} + +#[test] +fn rejects_empty_or_wrongly_shaped_inputs() { + let cases = [ + EvaluationRequest::jev(json!(null), questions()), + EvaluationRequest::jev(json!(true), questions()), + EvaluationRequest::jev("state", BTreeMap::new()), + ]; + for request in cases { + assert!(request.validate().is_err()); + } +} + +#[test] +fn enforces_choice_and_score_bounds() { + let choice = EvaluationRequest::jev( + "state", + BTreeMap::from([( + "choice".to_owned(), + Question::Choice(Choice { + instructions: json!("choose"), + criteria: BTreeMap::from([("only".to_owned(), None)]), + }), + )]), + ); + let score = EvaluationRequest::jev( + "state", + BTreeMap::from([( + "score".to_owned(), + Question::Score(Score { + instructions: json!("score"), + criteria: vec![json!("only")], + }), + )]), + ); + assert!(choice.validate().is_err()); + assert!(score.validate().is_err()); +} diff --git a/crates/tinyjevclient/src/request/types.rs b/crates/tinyjevclient/src/request/types.rs new file mode 100644 index 0000000..3e4afcd --- /dev/null +++ b/crates/tinyjevclient/src/request/types.rs @@ -0,0 +1,78 @@ +//! Stable request payload definitions. + +use std::collections::BTreeMap; + +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +/// One complete System One evaluation request. +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +pub struct EvaluationRequest { + /// Unstructured text or structured application state visible to every question. + pub state: Value, + /// System One model identifier, normally `jev-latest`. + pub model: String, + /// Independently evaluated questions keyed by caller-owned ids. + pub questions: BTreeMap, +} + +impl EvaluationRequest { + /// Build a request using the stable Jev alias. + #[must_use] + pub fn jev(state: impl Into, questions: BTreeMap) -> Self { + Self { + state: state.into(), + model: "jev-latest".to_owned(), + questions, + } + } +} + +/// A typed question evaluated independently against shared state. +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum Question { + /// Select exactly one option from a closed set. + Choice(Choice), + /// Place the state along an ordered descriptive scale. + Score(Score), + /// Estimate the probability that a yes/no condition holds. + Noul(Noul), +} + +/// A closed-set selection question. +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +pub struct Choice { + /// Complete meaning of the decision to make. + pub instructions: Value, + /// Option name to optional distinguishing description. + pub criteria: BTreeMap>, +} + +/// An ordered-scale question. +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +pub struct Score { + /// Dimension being rated. + pub instructions: Value, + /// Ordered, standalone level descriptions. + pub criteria: Vec, +} + +/// A yes/no probability question. +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +pub struct Noul { + /// Condition whose probability of being true is requested. + pub instructions: Value, + /// Optional descriptions clarifying both outcomes. + #[serde(skip_serializing_if = "Option::is_none")] + pub criteria: Option, +} + +/// Descriptions of the true and false outcomes of a [`Noul`]. +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +pub struct NoulCriteria { + /// What counts as true. + pub r#true: Value, + /// What counts as false. + pub r#false: Value, +} diff --git a/crates/tinyjevclient/src/response/README.md b/crates/tinyjevclient/src/response/README.md new file mode 100644 index 0000000..7c963ba --- /dev/null +++ b/crates/tinyjevclient/src/response/README.md @@ -0,0 +1,5 @@ +# Response module + +This module decodes System One answers and validates them against the originating +request. `types.rs` mirrors the wire response, `mod.rs` rejects inconsistent ids, +types, distributions, legends, and scores, and `test.rs` pins those guarantees. diff --git a/crates/tinyjevclient/src/response/mod.rs b/crates/tinyjevclient/src/response/mod.rs new file mode 100644 index 0000000..fb7c1a5 --- /dev/null +++ b/crates/tinyjevclient/src/response/mod.rs @@ -0,0 +1,135 @@ +//! Typed System One responses and cross-checks against their requests. + +#[cfg(test)] +mod test; + +mod types; + +pub use types::{Answer, ChoiceAnswer, EvaluationResponse, NoulAnswer, ScoreAnswer, Usage}; + +use std::collections::BTreeSet; + +use crate::{Error, EvaluationRequest, Question, Result}; + +const PROBABILITY_TOLERANCE: f64 = 0.000_001; + +impl EvaluationResponse { + /// Check this response against the request that produced it. + /// + /// # Errors + /// + /// Returns [`Error::InvalidResponse`] when answer ids or primitive types do + /// not match the request, or when a probability payload is inconsistent. + pub fn validate_for(&self, request: &EvaluationRequest) -> Result<()> { + if self.model.trim().is_empty() { + return Err(Error::invalid_response("response model must not be empty")); + } + let expected: BTreeSet<&str> = request.questions.keys().map(String::as_str).collect(); + let actual: BTreeSet<&str> = self.answers.keys().map(String::as_str).collect(); + if actual != expected { + return Err(Error::invalid_response( + "response answer ids must exactly match request question ids", + )); + } + for (id, question) in &request.questions { + let answer = self + .answers + .get(id) + .ok_or_else(|| Error::invalid_response("response answer is missing"))?; + validate_pair(question, answer)?; + } + Ok(()) + } +} + +fn validate_pair(question: &Question, answer: &Answer) -> Result<()> { + match (question, answer) { + (Question::Choice(question), Answer::Choice(answer)) => { + validate_probability(answer.confidence, "choice confidence")?; + validate_distribution(&answer.probabilities, "choice")?; + let expected: BTreeSet<&str> = question.criteria.keys().map(String::as_str).collect(); + let actual: BTreeSet<&str> = answer.probabilities.keys().map(String::as_str).collect(); + if actual != expected || !expected.contains(answer.choice.as_str()) { + return Err(Error::invalid_response( + "choice labels must exactly match request criteria", + )); + } + let selected = answer.probabilities[&answer.choice]; + if answer + .probabilities + .values() + .any(|probability| *probability > selected + PROBABILITY_TOLERANCE) + { + return Err(Error::invalid_response( + "choice must name a highest-probability option", + )); + } + } + (Question::Score(question), Answer::Score(answer)) => { + validate_probability(answer.confidence, "score confidence")?; + validate_distribution(&answer.probabilities, "score")?; + let expected: BTreeSet = (0..question.criteria.len()) + .map(|index| index.to_string()) + .collect(); + let actual: BTreeSet = answer.probabilities.keys().cloned().collect(); + let legend: BTreeSet = answer.legend.keys().cloned().collect(); + if actual != expected || legend != expected { + return Err(Error::invalid_response( + "score levels must exactly match request criteria", + )); + } + if !answer.score.is_finite() { + return Err(Error::invalid_response("score must be finite")); + } + let expected_score: f64 = answer + .probabilities + .iter() + .map(|(level, probability)| level.parse::().unwrap_or_default() * probability) + .sum(); + if (answer.score - expected_score).abs() > PROBABILITY_TOLERANCE { + return Err(Error::invalid_response( + "score must equal the probability-weighted level", + )); + } + } + (Question::Noul(_), Answer::Noul(answer)) => { + validate_probability(answer.noul, "noul")?; + } + _ => { + return Err(Error::invalid_response( + "answer type must match its request question type", + )); + } + } + Ok(()) +} + +fn validate_distribution( + probabilities: &std::collections::BTreeMap, + name: &str, +) -> Result<()> { + if probabilities.is_empty() { + return Err(Error::invalid_response(format!( + "{name} probabilities must not be empty" + ))); + } + for probability in probabilities.values() { + validate_probability(*probability, name)?; + } + let sum: f64 = probabilities.values().sum(); + if (sum - 1.0).abs() > PROBABILITY_TOLERANCE { + return Err(Error::invalid_response(format!( + "{name} probabilities must sum to one" + ))); + } + Ok(()) +} + +fn validate_probability(value: f64, name: &str) -> Result<()> { + if !value.is_finite() || !(0.0..=1.0).contains(&value) { + return Err(Error::invalid_response(format!( + "{name} must be between zero and one" + ))); + } + Ok(()) +} diff --git a/crates/tinyjevclient/src/response/test.rs b/crates/tinyjevclient/src/response/test.rs new file mode 100644 index 0000000..372c95d --- /dev/null +++ b/crates/tinyjevclient/src/response/test.rs @@ -0,0 +1,109 @@ +//! Response wire and request-relative validation tests. + +#![allow(clippy::unwrap_used, clippy::panic)] + +use std::collections::BTreeMap; + +use serde_json::json; + +use super::*; +use crate::{Choice, EvaluationRequest, Noul, Question, Score}; + +fn request() -> EvaluationRequest { + EvaluationRequest::jev( + "state", + BTreeMap::from([ + ( + "route".to_owned(), + Question::Choice(Choice { + instructions: json!("route"), + criteria: BTreeMap::from([("a".to_owned(), None), ("b".to_owned(), None)]), + }), + ), + ( + "quality".to_owned(), + Question::Score(Score { + instructions: json!("quality"), + criteria: vec![json!("low"), json!("high")], + }), + ), + ( + "safe".to_owned(), + Question::Noul(Noul { + instructions: json!("safe"), + criteria: None, + }), + ), + ]), + ) +} + +fn response() -> EvaluationResponse { + serde_json::from_value(json!({ + "model": "jev-latest", + "answers": { + "route": { + "type": "choice", + "choice": "b", + "probabilities": {"a": 0.25, "b": 0.75}, + "confidence": 0.5 + }, + "quality": { + "type": "score", + "score": 0.8, + "legend": {"0": "low", "1": "high"}, + "probabilities": {"0": 0.2, "1": 0.8}, + "confidence": 0.6 + }, + "safe": {"type": "noul", "noul": 0.9} + }, + "usage": {"input_tokens": 42, "output_tokens": 3} + })) + .unwrap() +} + +#[test] +fn validates_all_three_answer_types() { + response().validate_for(&request()).unwrap(); +} + +#[test] +fn usage_fields_remain_optional() { + let usage: Usage = serde_json::from_value(json!({})).unwrap(); + assert_eq!(usage, Usage::default()); +} + +#[test] +fn rejects_missing_extra_or_wrongly_typed_answers() { + let mut missing = response(); + missing.answers.remove("safe"); + assert!(missing.validate_for(&request()).is_err()); + + let mut wrong = response(); + wrong.answers.insert( + "safe".to_owned(), + Answer::Choice(ChoiceAnswer { + choice: "a".to_owned(), + probabilities: BTreeMap::from([("a".to_owned(), 1.0)]), + confidence: 1.0, + }), + ); + assert!(wrong.validate_for(&request()).is_err()); +} + +#[test] +fn rejects_invalid_distributions_and_inconsistent_scores() { + let mut distribution = response(); + let Answer::Choice(choice) = distribution.answers.get_mut("route").unwrap() else { + panic!("fixture answer should be a choice") + }; + choice.probabilities.insert("a".to_owned(), 0.75); + assert!(distribution.validate_for(&request()).is_err()); + + let mut score = response(); + let Answer::Score(answer) = score.answers.get_mut("quality").unwrap() else { + panic!("fixture answer should be a score") + }; + answer.score = 0.1; + assert!(score.validate_for(&request()).is_err()); +} diff --git a/crates/tinyjevclient/src/response/types.rs b/crates/tinyjevclient/src/response/types.rs new file mode 100644 index 0000000..b328970 --- /dev/null +++ b/crates/tinyjevclient/src/response/types.rs @@ -0,0 +1,71 @@ +//! Stable response payload definitions. + +use std::collections::BTreeMap; + +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +/// One decoded System One response. +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +pub struct EvaluationResponse { + /// Model that performed the evaluation. + pub model: String, + /// Answers keyed by the caller's question ids. + pub answers: BTreeMap, + /// Provider-reported token counts. + pub usage: Usage, +} + +/// Provider-reported token usage. +#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] +pub struct Usage { + /// Input tokens, when reported. + #[serde(default)] + pub input_tokens: Option, + /// Output tokens, when reported. + #[serde(default)] + pub output_tokens: Option, +} + +/// One typed answer. +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum Answer { + /// Closed-set selection and distribution. + Choice(ChoiceAnswer), + /// Ordered score and level distribution. + Score(ScoreAnswer), + /// Probability of yes. + Noul(NoulAnswer), +} + +/// Answer to a Choice question. +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +pub struct ChoiceAnswer { + /// Highest-probability option. + pub choice: String, + /// Probability for every requested option. + pub probabilities: BTreeMap, + /// Concentration of the distribution, not correctness probability. + pub confidence: f64, +} + +/// Answer to a Score question. +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +pub struct ScoreAnswer { + /// Probability-weighted position along the scale. + pub score: f64, + /// Requested level descriptions keyed by zero-based index. + pub legend: BTreeMap, + /// Probability for every level. + pub probabilities: BTreeMap, + /// Concentration of the distribution, not correctness probability. + pub confidence: f64, +} + +/// Answer to a Noul question. +#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Serialize)] +pub struct NoulAnswer { + /// Probability that the requested condition is true. + pub noul: f64, +} diff --git a/crates/tinyjevclient/tests/public_api.rs b/crates/tinyjevclient/tests/public_api.rs new file mode 100644 index 0000000..b56bdd9 --- /dev/null +++ b/crates/tinyjevclient/tests/public_api.rs @@ -0,0 +1,26 @@ +//! Public surface smoke tests. + +#![allow(clippy::expect_used)] + +use std::collections::BTreeMap; + +use serde_json::json; +use tinyjevclient::{Choice, EvaluationRequest, Question}; + +#[test] +fn public_types_build_a_valid_jev_request() { + let request = EvaluationRequest::jev( + "route this", + BTreeMap::from([( + "route".to_owned(), + Question::Choice(Choice { + instructions: json!("Who should handle this?"), + criteria: BTreeMap::from([ + ("planner".to_owned(), None), + ("reviewer".to_owned(), None), + ]), + }), + )]), + ); + request.validate().expect("public request should validate"); +} diff --git a/deny.toml b/deny.toml index 1134b6a..e105108 100644 --- a/deny.toml +++ b/deny.toml @@ -11,7 +11,7 @@ ignore = [] [licenses] # Licenses accepted for this crate and its dependencies. Keep GPL-3.0-only for -# the template crate itself; the remaining entries cover compatible dependency +# this crate itself; the remaining entries cover compatible dependency # licenses commonly encountered by Rust projects. allow = [ "Apache-2.0", diff --git a/docs/README.md b/docs/README.md index 0c0f2b1..faec297 100644 --- a/docs/README.md +++ b/docs/README.md @@ -28,10 +28,9 @@ docs/ Complex modules also carry a module-level `README.md` inside `src//` covering their design, public surface, and important constraints. -The current module-release contract is in -[`specs/tinybus-module-release.md`](specs/tinybus-module-release.md), with its -implementation sequence in -[`plans/tinybus-module-release.md`](plans/tinybus-module-release.md). +The current client contract is in +[`specs/system-one-client.md`](specs/system-one-client.md), with its +implementation sequence in [`plans/system-one-client.md`](plans/system-one-client.md). ## Conventions diff --git a/docs/plans/README.md b/docs/plans/README.md index 0a5db16..ec5ef83 100644 --- a/docs/plans/README.md +++ b/docs/plans/README.md @@ -19,4 +19,4 @@ Prefer tasks that can be implemented and reviewed independently. Include short code snippets when they remove ambiguity, but do not paste entire future files into the plan. -See [`example-retry-policy.md`](example-retry-policy.md) for a test-first sample. +The implemented plan is [`system-one-client.md`](system-one-client.md). diff --git a/docs/plans/example-retry-policy.md b/docs/plans/example-retry-policy.md deleted file mode 100644 index fe42c4c..0000000 --- a/docs/plans/example-retry-policy.md +++ /dev/null @@ -1,71 +0,0 @@ -# Example plan: Retry policy - -- **Status:** Example -- **Specification:** - [`../specs/example-retry-policy.md`](../specs/example-retry-policy.md) - -> This is a sample implementation plan, not active work. Replace or remove it -> when generating a real project from this template. - -## Goal - -Add the specified typed retry policy through small red-green-refactor steps, -without adding a runtime, timers, or new dependencies. - -## Task 1: Add the constructor contract - -**Files:** `src/retry/mod.rs`, `src/retry/types.rs`, `src/retry/test.rs` - -1. Create the module skeleton and a failing test for zero attempts: - - ```rust - #[test] - fn rejects_zero_max_attempts() { - assert_eq!( - RetryPolicy::new(0).unwrap_err(), - Error::ZeroMaxAttempts, - ); - } - ``` - -2. Add `Error::ZeroMaxAttempts` in `src/error/mod.rs` and its message assertion - in `src/error/test.rs`. -3. Implement `RetryPolicy::new` using `NonZeroU32`, keeping the field private. -4. Run `cargo test retry` and `cargo clippy --all-targets --all-features -- -D warnings`. - -## Task 2: Add attempt-boundary behavior - -**Files:** `src/retry/mod.rs`, `src/retry/test.rs` - -1. Add failing tests for attempts `0`, `1`, the maximum, and one past it. -2. Implement the smallest boundary check: - - ```rust - #[must_use] - pub fn allows_attempt(self, attempt: u32) -> bool { - attempt != 0 && attempt <= self.max_attempts.get() - } - ``` - -3. Run `cargo test retry`. - -## Task 3: Publish and document the API - -**Files:** `src/lib.rs`, `tests/public_api.rs`, `README.md` - -1. Re-export `RetryPolicy` from `src/lib.rs`. -2. Add an integration test using only `template::{Error, RetryPolicy}`. -3. Add a runnable README example and rustdoc `# Errors` documentation. -4. Run `cargo test --doc` and `cargo test --test public_api`. - -## Task 4: Full verification - -- [ ] `cargo fmt --all -- --check` -- [ ] `cargo clippy --all-targets --all-features -- -D warnings` -- [ ] `cargo build --all-targets --all-features` -- [ ] `cargo test --all-features` -- [ ] `RUSTDOCFLAGS="-D warnings" cargo doc --no-deps --all-features` -- [ ] `cargo deny check all` - -When all checks pass, mark the specification Implemented and replace this -example status with the actual completion state. diff --git a/docs/plans/system-one-client.md b/docs/plans/system-one-client.md new file mode 100644 index 0000000..0507392 --- /dev/null +++ b/docs/plans/system-one-client.md @@ -0,0 +1,7 @@ +# Implement the System One client + +1. Replace the TinyBus template with one ordinary Rust library crate. +2. Define and pin Choice, Score, Noul, request, answer, usage, and response wires. +3. Validate requests and request-relative response invariants. +4. Add a rustls client with secret redaction, classified failures, and bounded retries. +5. Test all wire, validation, transport, retry, and public API behavior. diff --git a/docs/plans/tinybus-module-release.md b/docs/plans/tinybus-module-release.md deleted file mode 100644 index f9eaa18..0000000 --- a/docs/plans/tinybus-module-release.md +++ /dev/null @@ -1,11 +0,0 @@ -# Implement TinyBus Module Releases - -Linked specification: [`../specs/tinybus-module-release.md`](../specs/tinybus-module-release.md) - -1. Add the pinned TinyBus host types and module SDK as path dependencies. -2. Export the template greeting behavior through TinyBus module ABI v1. -3. Exercise the declared interface over the real in-memory bus. -4. Replace TinyBus host bundles with tagged `template` module archives for - every supported platform runner and distribution container. -5. Run the repository validation and coverage contracts, push `main`, and - trigger a patch release. diff --git a/docs/specs/README.md b/docs/specs/README.md index a8286ae..f8eb150 100644 --- a/docs/specs/README.md +++ b/docs/specs/README.md @@ -20,4 +20,4 @@ After the specification is accepted, create a linked implementation plan in [`../plans/`](../plans/README.md). Keep code snippets small enough to clarify the contract; production code still belongs under `src/`. -See [`example-retry-policy.md`](example-retry-policy.md) for a complete sample. +The implemented contract is [`system-one-client.md`](system-one-client.md). diff --git a/docs/specs/example-retry-policy.md b/docs/specs/example-retry-policy.md deleted file mode 100644 index fe0c1a8..0000000 --- a/docs/specs/example-retry-policy.md +++ /dev/null @@ -1,63 +0,0 @@ -# Example: Retry policy - -- **Status:** Example -- **Owner:** Maintainers -- **Plan:** [`../plans/example-retry-policy.md`](../plans/example-retry-policy.md) - -> This demonstrates the expected specification format. Replace or remove it -> when generating a real project from this template. - -## Problem - -Callers need a typed way to limit retries without duplicating attempt counting -and validation. The crate currently has no retry behavior. - -## Goals - -- Expose an immutable retry policy with a non-zero maximum attempt count. -- Let callers determine whether another attempt is permitted. -- Reject zero attempts through the crate-wide error type. - -## Non-goals - -- Sleeping, backoff, jitter, or executing operations. -- Deciding which application-specific errors are retryable. -- Persisting retry state. - -## Proposed behavior - -The public surface is deliberately small: - -```rust -use template::{RetryPolicy, Result}; - -fn policy() -> Result { - let policy = RetryPolicy::new(3)?; - assert!(policy.allows_attempt(1)); - assert!(!policy.allows_attempt(4)); - Ok(policy) -} -``` - -`RetryPolicy::new(0)` returns a dedicated `Error::ZeroMaxAttempts` variant. -Attempt numbers are one-based: attempt `1` is the initial call, not the first -retry. - -## Invariants and constraints - -- `max_attempts` is always greater than zero after construction. -- `allows_attempt(n)` is true exactly when `1 <= n <= max_attempts`. -- The type is cheap to copy and does not perform I/O or observe time. -- New public items have rustdoc and are re-exported from `src/lib.rs`. - -## Acceptance criteria - -- Construction succeeds for `1` and `u32::MAX` and fails for `0`. -- Boundary checks cover attempts `0`, `1`, `max_attempts`, and - `max_attempts + 1` when representable. -- Integration tests prove the policy and its error are available to consumers. -- Formatting, Clippy, build, tests, rustdoc, and cargo-deny pass. - -## Open questions - -None for this example. diff --git a/docs/specs/system-one-client.md b/docs/specs/system-one-client.md new file mode 100644 index 0000000..f7910f3 --- /dev/null +++ b/docs/specs/system-one-client.md @@ -0,0 +1,23 @@ +# System One client + +## Contract + +The client mirrors `POST /v1/systemone`: state is a string, object, or array; +questions are a nonempty map of Choice, Score, and Noul values; answers return +under the same ids. Choice accepts 2–255 options, Score accepts 2–10 concrete +levels, and Noul may describe its true and false criteria. + +The client validates request bounds before transport and validates response ids, +answer types, probability ranges and sums, selected maxima, Score legends, and +weighted Score values before returning. Typed output is an interface guarantee, +not a truth guarantee; applications evaluate accuracy and thresholds on their +own data. + +## Failure policy + +Authentication and request errors are terminal. Transport failures, timeouts, +rate limits, overload, and server errors use a bounded caller-visible retry +policy. No retry is unbounded, and the result reports every attempt. + +Credentials never appear in `Debug`, error messages, or retained response +bodies. Application state and provider bodies are not logged by the crate. diff --git a/docs/specs/tinybus-module-release.md b/docs/specs/tinybus-module-release.md deleted file mode 100644 index adae9b4..0000000 --- a/docs/specs/tinybus-module-release.md +++ /dev/null @@ -1,33 +0,0 @@ -# TinyBus Module Release - -## Purpose - -Generated projects must be usable as native TinyBus integrations and -distributable without also shipping the TinyBus host runtime. - -## Contract - -- The library builds as both an `rlib` and a native `cdylib`. -- The `cdylib` exports TinyBus module ABI v1, an embedded manifest, and the - initialization entrypoint. -- The example module provides `ai.tinyhumans.template.Greeting.Greet` at - `/ai/tinyhumans/template/Greeting`. -- Each release archive is named - `template--.` and contains only this - module, its SHA-256 `modules.toml`, license, and installation documentation. -- Each GitHub release publishes a separate `checksum.toml` mapping every - archive filename to its SHA-256 digest for TinyBus's release loader. -- Release builds cover the stable native Ubuntu, macOS, and Windows runners, - Fedora 43/44 containers, and rolling Arch Linux where official runners or - images exist for the architecture. -- TinyBus itself remains a pinned SDK submodule and is not shipped as a release - asset from this repository. - -## Verification - -CI exercises the bus interface through TinyBus's in-memory transport, enforces -90% line coverage in every source file, and builds the `cdylib`. The release -workflow builds each native module from the tagged source and records its exact -digest in the adjacent allowlist. After publishing, it downloads the Ubuntu -x86_64 archive through TinyBus's GitHub release API and calls `Greet` over an -in-memory bus. diff --git a/skills-lock.json b/skills-lock.json new file mode 100644 index 0000000..734aa12 --- /dev/null +++ b/skills-lock.json @@ -0,0 +1,11 @@ +{ + "version": 1, + "skills": { + "typesafe-ai": { + "source": "typesafe-ai/skills", + "sourceType": "github", + "skillPath": "skills/typesafe-ai/SKILL.md", + "computedHash": "9cd84c5e535dec8dec59917c110f9c00b4a61faadb86b432ec7e41051170af12" + } + } +} From a5384036419568a50c4bf9d9f5b9e8d6fc23e024 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Thu, 17 Sep 2026 18:28:24 +0530 Subject: [PATCH 02/10] Remove the Rust module scaffold --- crates/template/Cargo.toml | 39 ------------------ crates/template/examples/basic.rs | 22 ---------- crates/template/src/error/mod.rs | 28 ------------- crates/template/src/lib.rs | 62 ----------------------------- crates/template/tests/public_api.rs | 20 ---------- 5 files changed, 171 deletions(-) delete mode 100644 crates/template/Cargo.toml delete mode 100644 crates/template/examples/basic.rs delete mode 100644 crates/template/src/error/mod.rs delete mode 100644 crates/template/src/lib.rs delete mode 100644 crates/template/tests/public_api.rs diff --git a/crates/template/Cargo.toml b/crates/template/Cargo.toml deleted file mode 100644 index e1bcdf4..0000000 --- a/crates/template/Cargo.toml +++ /dev/null @@ -1,39 +0,0 @@ -[package] -name = "template" -version.workspace = true -edition.workspace = true -rust-version.workspace = true -license.workspace = true -repository.workspace = true -description = "A production-ready template for installable TinyBus modules." -documentation = "https://docs.rs/template" -readme = "../../README.md" -keywords = ["tinybus", "module", "plugin", "template"] -categories = ["development-tools"] -publish = false - -[lib] -# Keep the ordinary Rust library for tests and downstream reuse while also -# producing the native module artifact that TinyBus loads at runtime. -crate-type = ["rlib", "cdylib"] - -[dependencies] -# The wire contract: member names, payload types, and the contract version. -# Re-exported wholesale from `src/lib.rs` so a consumer takes one dependency -# rather than two, and so `template::GreetRequest` and -# `template_bus::GreetRequest` are the same type. -template-bus = { workspace = true } -tinybus = { workspace = true } -tinybus-module = { workspace = true } -thiserror = { workspace = true } - -[dev-dependencies] -tokio = { workspace = true } -# The GitHub release verifier passes an explicit empty module configuration. -serde_json = { workspace = true } - -[features] -default = [] - -[lints] -workspace = true diff --git a/crates/template/examples/basic.rs b/crates/template/examples/basic.rs deleted file mode 100644 index 99233ec..0000000 --- a/crates/template/examples/basic.rs +++ /dev/null @@ -1,22 +0,0 @@ -//! Minimal end-to-end usage of the crate. -//! -//! Examples are compiled and linted in CI, so they cannot drift from the API. -//! Run it with: -//! -//! ```sh -//! cargo run --example basic -//! ``` - -use template::{Result, greet}; - -fn main() -> Result<()> { - println!("{}", greet("Rust")?); - - // Failure modes are part of the public contract; show them too. - match greet(" ") { - Ok(greeting) => println!("{greeting}"), - Err(error) => println!("expected failure: {error}"), - } - - Ok(()) -} diff --git a/crates/template/src/error/mod.rs b/crates/template/src/error/mod.rs deleted file mode 100644 index b8ddbe0..0000000 --- a/crates/template/src/error/mod.rs +++ /dev/null @@ -1,28 +0,0 @@ -//! Crate-wide error and result types. -//! -//! Every fallible public function in this crate returns [`Result`], and every -//! failure mode is a distinct [`Error`] variant. Add a variant rather than -//! encoding new context into an existing message: callers match on variants, -//! and message text is not a stable API. -//! -//! Variants carry the data a caller needs to react, keep their `#[error]` -//! message lowercase and free of trailing punctuation, and are documented so -//! the rendered rustdoc explains when each one occurs. - -/// Errors returned by this crate. -#[derive(Debug, thiserror::Error, PartialEq, Eq)] -#[non_exhaustive] -pub enum Error { - /// A required name was empty or contained only whitespace. - #[error("name must not be empty")] - EmptyName, -} - -/// The crate's standard result type. -/// -/// Use this alias in public signatures instead of spelling out -/// `std::result::Result`. -pub type Result = std::result::Result; - -#[cfg(test)] -mod test; diff --git a/crates/template/src/lib.rs b/crates/template/src/lib.rs deleted file mode 100644 index 566fa7e..0000000 --- a/crates/template/src/lib.rs +++ /dev/null @@ -1,62 +0,0 @@ -//! A production-ready starting point for an installable `TinyBus` module. -//! -//! This crate is a template. It ships the layout, lint configuration, error -//! handling, testing, and documentation conventions described in `AGENTS.md`. -//! The compiled `cdylib` exports `TinyBus` module ABI v1 and serves the example -//! [`greet`] behavior over the bus. -//! -//! # Layout -//! -//! This is the implementation half of a two-crate workspace: -//! -//! - [`template_bus`] — the wire contract. Member names, payload types, and the -//! contract version, with no transport and no behavior. A host that only -//! makes calls depends on that crate alone. -//! - `template` — this crate. The behavior, the crate-wide error type, and the -//! `TinyBus` adapter that serves them, built as both an `rlib` and the -//! `cdylib` the loader consumes. -//! -//! Within this crate: -//! -//! - `src/error/` holds the crate-wide [`Error`] enum and the [`Result`] alias -//! returned by every fallible public function. -//! - Each feature area lives in its own module directory with a `mod.rs` -//! module root, an optional `types.rs`, and a `test.rs` holding its unit -//! tests. -//! - Every public item is re-exported from here — including all of -//! [`template_bus`] — so downstream users have a single predictable surface -//! and `template::GreetRequest` is the *same type* as -//! `template_bus::GreetRequest`, not a structural twin. -//! - `tinybus_module` adapts the public behavior to `TinyBus` and exports the -//! module descriptor, embedded manifest, and initialization entrypoint. -//! -//! # Example -//! -//! ``` -//! use template::{greet, Error, GreetRequest}; -//! -//! assert_eq!(greet("Ferris")?, "Hello, Ferris!"); -//! assert_eq!(greet(" ").unwrap_err(), Error::EmptyName); -//! assert_eq!(GreetRequest::new("Ferris").name, "Ferris"); -//! # Ok::<(), template::Error>(()) -//! ``` -//! -//! Replace the `greeting` module with the first real feature area, keep the -//! conventions, and update this documentation to describe the new crate. - -mod error; -mod greeting; -mod tinybus_module; - -pub use error::{Error, Result}; -pub use greeting::greet; - -// The wire contract, re-exported by module rather than by item so every path -// through this crate resolves to the same definitions the contract crate -// publishes. A host may depend on `template-bus` directly and get exactly these -// types; nothing here redefines them. -pub use template_bus; -pub use template_bus::{ - CONTRACT_VERSION, GreetRequest, GreetResponse, INTERFACE, METHODS, OBJECT_PATH, is_compatible, - names, version, -}; diff --git a/crates/template/tests/public_api.rs b/crates/template/tests/public_api.rs deleted file mode 100644 index 256b71c..0000000 --- a/crates/template/tests/public_api.rs +++ /dev/null @@ -1,20 +0,0 @@ -//! Integration tests for the public crate surface. -//! -//! These tests link against the crate as a downstream consumer would: they can -//! only use what `src/lib.rs` re-exports. Treat them as the regression suite -//! for the crate's public contract — if a change breaks a test here, it is a -//! breaking change for users. - -#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] - -use template::{Error, greet}; - -#[test] -fn greeting_is_available_to_consumers() { - assert_eq!(greet("Rust").unwrap(), "Hello, Rust!"); -} - -#[test] -fn errors_are_available_to_consumers() { - assert_eq!(greet("").unwrap_err(), Error::EmptyName); -} From 21207aaf7da1efd3a3da7a546e486fc53ab4f3ed Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Thu, 17 Sep 2026 18:28:29 +0530 Subject: [PATCH 03/10] Remove the vendored TinyBus SDK --- vendor/tinybus | 1 - 1 file changed, 1 deletion(-) delete mode 160000 vendor/tinybus diff --git a/vendor/tinybus b/vendor/tinybus deleted file mode 160000 index 92b817e..0000000 --- a/vendor/tinybus +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 92b817e42ecb980628166dd31b465524130de2f5 From bf9c1d8210dd892c22f3ece4d1ebb518ee620836 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Thu, 17 Sep 2026 18:31:01 +0530 Subject: [PATCH 04/10] Raise client contract coverage --- crates/tinyjevclient/src/client/test.rs | 114 +++++++++++++++++++++- crates/tinyjevclient/src/request/test.rs | 66 +++++++++++++ crates/tinyjevclient/src/response/test.rs | 69 +++++++++++++ 3 files changed, 248 insertions(+), 1 deletion(-) diff --git a/crates/tinyjevclient/src/client/test.rs b/crates/tinyjevclient/src/client/test.rs index 186e106..0d3f421 100644 --- a/crates/tinyjevclient/src/client/test.rs +++ b/crates/tinyjevclient/src/client/test.rs @@ -71,6 +71,16 @@ async fn server(responses: Vec) -> (String, Arc>>) { (format!("http://{address}"), requests) } +async fn slow_server() -> String { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + tokio::spawn(async move { + let (_stream, _) = listener.accept().await.unwrap(); + tokio::time::sleep(Duration::from_millis(100)).await; + }); + format!("http://{address}") +} + fn config(base_url: String) -> ClientConfig { let mut config = ClientConfig::new("secret-test-key"); config.base_url = base_url; @@ -148,7 +158,12 @@ async fn malformed_success_body_is_a_decode_failure() { #[tokio::test] async fn debug_output_redacts_the_api_key() { - let rendered = format!("{:?}", config("http://127.0.0.1:1".to_owned())); + let config = config("http://127.0.0.1:1".to_owned()); + let rendered = format!("{config:?}"); + assert!(rendered.contains("[REDACTED]")); + assert!(!rendered.contains("secret-test-key")); + let client = Client::new(config).unwrap(); + let rendered = format!("{client:?}"); assert!(rendered.contains("[REDACTED]")); assert!(!rendered.contains("secret-test-key")); } @@ -162,3 +177,100 @@ fn rejects_invalid_configuration_before_transport() { Err(Error::InvalidConfig { .. }) )); } + +#[test] +fn validates_every_configuration_bound_and_redacted_key_replacement() { + let replaced = ClientConfig::new("old").with_api_key("new-secret"); + let rendered = format!("{replaced:?}"); + assert!(!rendered.contains("new-secret")); + + let mut scheme = ClientConfig::new("key"); + scheme.base_url = "file:///tmp/socket".into(); + assert!(matches!( + Client::new(scheme), + Err(Error::InvalidConfig { .. }) + )); + + let mut timeout = ClientConfig::new("key"); + timeout.timeout = Duration::ZERO; + assert!(matches!( + Client::new(timeout), + Err(Error::InvalidConfig { .. }) + )); + + let mut retry = ClientConfig::new("key"); + retry.retry.initial_backoff = Duration::ZERO; + assert!(matches!( + Client::new(retry), + Err(Error::InvalidConfig { .. }) + )); +} + +#[test] +fn retry_delay_is_exponential_and_bounded() { + let policy = RetryPolicy { + max_retries: 5, + initial_backoff: Duration::from_millis(10), + max_backoff: Duration::from_millis(25), + }; + assert_eq!(policy.delay(1), Duration::from_millis(10)); + assert_eq!(policy.delay(2), Duration::from_millis(20)); + assert_eq!(policy.delay(30), Duration::from_millis(25)); +} + +#[test] +fn status_classification_covers_terminal_and_retryable_classes() { + assert!(matches!( + classify_status(StatusCode::BAD_REQUEST, None), + Failure::Terminal(Error::Unprocessable) + )); + assert!(matches!( + classify_status(StatusCode::NOT_FOUND, None), + Failure::Terminal(Error::HttpStatus { status: 404 }) + )); + assert!(matches!( + classify_status(StatusCode::INTERNAL_SERVER_ERROR, None), + Failure::Retryable { + error: Error::HttpStatus { status: 500 }, + .. + } + )); + assert!(matches!( + classify_status(StatusCode::from_u16(529).unwrap(), None), + Failure::Retryable { + error: Error::Overloaded, + .. + } + )); + assert_eq!( + parse_retry_after(Some(&reqwest::header::HeaderValue::from_static("3"))), + Some(Duration::from_secs(3)) + ); + assert_eq!( + parse_retry_after(Some(&reqwest::header::HeaderValue::from_static("date"))), + None + ); +} + +#[tokio::test] +async fn timeout_is_retryable_but_respects_the_attempt_bound() { + let mut config = config(slow_server().await); + config.timeout = Duration::from_millis(5); + let error = Client::new(config) + .unwrap() + .evaluate(&request()) + .await + .unwrap_err(); + assert!(matches!(error, Error::Timeout)); +} + +#[tokio::test] +async fn exhausted_rate_limit_returns_the_classified_error() { + let (base_url, _) = server(vec![response(429, "{}", "")]).await; + let error = Client::new(config(base_url)) + .unwrap() + .evaluate(&request()) + .await + .unwrap_err(); + assert!(matches!(error, Error::RateLimited)); +} diff --git a/crates/tinyjevclient/src/request/test.rs b/crates/tinyjevclient/src/request/test.rs index 371dcb3..66f118b 100644 --- a/crates/tinyjevclient/src/request/test.rs +++ b/crates/tinyjevclient/src/request/test.rs @@ -118,3 +118,69 @@ fn enforces_choice_and_score_bounds() { assert!(choice.validate().is_err()); assert!(score.validate().is_err()); } + +#[test] +fn rejects_blank_model_ids_instructions_and_criteria() { + let mut model = EvaluationRequest::jev("state", questions()); + model.model = " ".into(); + assert!(model.validate().is_err()); + + let mut id = EvaluationRequest::jev("state", questions()); + let question = id.questions.remove("route").unwrap(); + id.questions.insert(" ".into(), question); + assert!(id.validate().is_err()); + + let blank_choice = EvaluationRequest::jev( + "state", + BTreeMap::from([( + "choice".into(), + Question::Choice(Choice { + instructions: json!(" "), + criteria: BTreeMap::from([("a".into(), None), ("b".into(), None)]), + }), + )]), + ); + assert!(blank_choice.validate().is_err()); + + let empty_option = EvaluationRequest::jev( + "state", + BTreeMap::from([( + "choice".into(), + Question::Choice(Choice { + instructions: json!("choose"), + criteria: BTreeMap::from([("".into(), None), ("b".into(), None)]), + }), + )]), + ); + assert!(empty_option.validate().is_err()); +} + +#[test] +fn rejects_blank_score_and_noul_descriptions() { + let score = EvaluationRequest::jev( + "state", + BTreeMap::from([( + "score".into(), + Question::Score(Score { + instructions: json!("rate"), + criteria: vec![json!("low"), json!(" ")], + }), + )]), + ); + assert!(score.validate().is_err()); + + let noul = EvaluationRequest::jev( + "state", + BTreeMap::from([( + "noul".into(), + Question::Noul(Noul { + instructions: json!("is it safe?"), + criteria: Some(NoulCriteria { + r#true: json!(" "), + r#false: json!("not safe"), + }), + }), + )]), + ); + assert!(noul.validate().is_err()); +} diff --git a/crates/tinyjevclient/src/response/test.rs b/crates/tinyjevclient/src/response/test.rs index 372c95d..f12f837 100644 --- a/crates/tinyjevclient/src/response/test.rs +++ b/crates/tinyjevclient/src/response/test.rs @@ -107,3 +107,72 @@ fn rejects_invalid_distributions_and_inconsistent_scores() { answer.score = 0.1; assert!(score.validate_for(&request()).is_err()); } + +#[test] +fn rejects_empty_model_extra_ids_and_nonmaximal_choice() { + let mut empty_model = response(); + empty_model.model.clear(); + assert!(empty_model.validate_for(&request()).is_err()); + + let mut extra = response(); + extra + .answers + .insert("extra".into(), Answer::Noul(NoulAnswer { noul: 0.5 })); + assert!(extra.validate_for(&request()).is_err()); + + let mut nonmaximal = response(); + let Answer::Choice(choice) = nonmaximal.answers.get_mut("route").unwrap() else { + panic!("fixture answer should be a choice") + }; + choice.choice = "a".into(); + assert!(nonmaximal.validate_for(&request()).is_err()); +} + +#[test] +fn rejects_out_of_range_empty_and_mismatched_probability_payloads() { + let mut confidence = response(); + let Answer::Choice(choice) = confidence.answers.get_mut("route").unwrap() else { + panic!("fixture answer should be a choice") + }; + choice.confidence = 1.1; + assert!(confidence.validate_for(&request()).is_err()); + + let mut empty = response(); + let Answer::Choice(choice) = empty.answers.get_mut("route").unwrap() else { + panic!("fixture answer should be a choice") + }; + choice.probabilities.clear(); + assert!(empty.validate_for(&request()).is_err()); + + let mut labels = response(); + let Answer::Choice(choice) = labels.answers.get_mut("route").unwrap() else { + panic!("fixture answer should be a choice") + }; + choice.probabilities.remove("a"); + choice.probabilities.insert("c".into(), 0.25); + assert!(labels.validate_for(&request()).is_err()); + + let mut noul = response(); + let Answer::Noul(answer) = noul.answers.get_mut("safe").unwrap() else { + panic!("fixture answer should be a noul") + }; + answer.noul = f64::NAN; + assert!(noul.validate_for(&request()).is_err()); +} + +#[test] +fn rejects_nonfinite_score_and_mismatched_legend() { + let mut nonfinite = response(); + let Answer::Score(score) = nonfinite.answers.get_mut("quality").unwrap() else { + panic!("fixture answer should be a score") + }; + score.score = f64::INFINITY; + assert!(nonfinite.validate_for(&request()).is_err()); + + let mut legend = response(); + let Answer::Score(score) = legend.answers.get_mut("quality").unwrap() else { + panic!("fixture answer should be a score") + }; + score.legend.remove("1"); + assert!(legend.validate_for(&request()).is_err()); +} From f9a6d1bbc8a2bfbbc5d4b5d3a052a64247caacf3 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Thu, 17 Sep 2026 18:31:36 +0530 Subject: [PATCH 05/10] Fix client test lint --- crates/tinyjevclient/src/request/test.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/tinyjevclient/src/request/test.rs b/crates/tinyjevclient/src/request/test.rs index 66f118b..2e1d822 100644 --- a/crates/tinyjevclient/src/request/test.rs +++ b/crates/tinyjevclient/src/request/test.rs @@ -148,7 +148,7 @@ fn rejects_blank_model_ids_instructions_and_criteria() { "choice".into(), Question::Choice(Choice { instructions: json!("choose"), - criteria: BTreeMap::from([("".into(), None), ("b".into(), None)]), + criteria: BTreeMap::from([(String::new(), None), ("b".into(), None)]), }), )]), ); From 3042c501fa28fae9972c1761543ed94931d79607 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Thu, 17 Sep 2026 19:11:11 +0530 Subject: [PATCH 06/10] Harden client validation and failure reporting --- Cargo.lock | 15 ++-- Cargo.toml | 2 + README.md | 6 +- crates/tinyjevclient/Cargo.toml | 1 + crates/tinyjevclient/examples/basic.rs | 2 +- crates/tinyjevclient/src/client/README.md | 4 ++ crates/tinyjevclient/src/client/mod.rs | 68 +++++++++++++++--- crates/tinyjevclient/src/client/test.rs | 86 +++++++++++++++++++++-- crates/tinyjevclient/src/client/types.rs | 15 +++- crates/tinyjevclient/src/lib.rs | 4 +- crates/tinyjevclient/src/request/mod.rs | 7 +- crates/tinyjevclient/src/request/test.rs | 14 ++++ crates/tinyjevclient/src/response/mod.rs | 7 +- crates/tinyjevclient/src/response/test.rs | 8 +++ docs/plans/system-one-client.md | 2 + docs/specs/system-one-client.md | 4 +- 16 files changed, 219 insertions(+), 26 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e2b44b7..713f6fd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -199,6 +199,12 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + [[package]] name = "hyper" version = "1.11.1" @@ -619,9 +625,9 @@ checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" [[package]] name = "rustls" -version = "0.23.43" +version = "0.23.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" +checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" dependencies = [ "once_cell", "ring", @@ -643,9 +649,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.103.13" +version = "0.103.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" dependencies = [ "ring", "rustls-pki-types", @@ -814,6 +820,7 @@ dependencies = [ name = "tinyjevclient" version = "0.2.1" dependencies = [ + "httpdate", "reqwest", "serde", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index 5f5e862..065cf4b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,6 +27,8 @@ serde = { version = "1", features = ["derive"] } serde_json = "1" # The client uses a rustls-backed HTTP transport and no platform TLS library. reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } +# Parses the HTTP-date form of Retry-After without accepting locale-dependent dates. +httpdate = "1" # Bounded retry delays are asynchronous and inherit the caller's Tokio runtime. tokio = { version = "1", features = ["time"] } diff --git a/README.md b/README.md index 870a836..60f757b 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ use std::collections::BTreeMap; use serde_json::json; use tinyjevclient::{Choice, Client, EvaluationRequest, Question}; -# async fn run() -> tinyjevclient::Result<()> { +# async fn run() -> Result<(), Box> { let request = EvaluationRequest::jev( json!({"ticket": "I was charged twice"}), BTreeMap::from([( @@ -44,4 +44,8 @@ real API call: TYPESAFE_API_KEY='' cargo run -p tinyjevclient --example basic ``` +Remote API roots must use HTTPS; HTTP is reserved for literal loopback IPs. +Failed evaluations retain their classified error, attempt count, and elapsed +time so reliability measurements do not lose unsuccessful work. + The repository is GPL-3.0-only and is consumed by pinned git revision. diff --git a/crates/tinyjevclient/Cargo.toml b/crates/tinyjevclient/Cargo.toml index 5de57b6..960054a 100644 --- a/crates/tinyjevclient/Cargo.toml +++ b/crates/tinyjevclient/Cargo.toml @@ -14,6 +14,7 @@ publish = false [dependencies] reqwest = { workspace = true } +httpdate = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } thiserror = { workspace = true } diff --git a/crates/tinyjevclient/examples/basic.rs b/crates/tinyjevclient/examples/basic.rs index ac228b8..43364ff 100644 --- a/crates/tinyjevclient/examples/basic.rs +++ b/crates/tinyjevclient/examples/basic.rs @@ -6,7 +6,7 @@ use serde_json::json; use tinyjevclient::{Choice, Client, EvaluationRequest, Question}; #[tokio::main] -async fn main() -> tinyjevclient::Result<()> { +async fn main() -> Result<(), Box> { let request = EvaluationRequest::jev( json!({"ticket": "I was charged twice. Please fix this."}), BTreeMap::from([( diff --git a/crates/tinyjevclient/src/client/README.md b/crates/tinyjevclient/src/client/README.md index 4bf3dfc..65d1644 100644 --- a/crates/tinyjevclient/src/client/README.md +++ b/crates/tinyjevclient/src/client/README.md @@ -4,3 +4,7 @@ The client validates a request, sends it to the System One endpoint, classifies HTTP failures, retries only transient failures, validates the response against the original questions, and returns attempts and end-to-end latency. API keys remain private and render only as `[REDACTED]`. + +Production endpoints require HTTPS. Plain HTTP is accepted only for literal +loopback IP addresses used by local tests and development services. Both +successful and failed evaluations report attempts and end-to-end latency. diff --git a/crates/tinyjevclient/src/client/mod.rs b/crates/tinyjevclient/src/client/mod.rs index 131c909..396c486 100644 --- a/crates/tinyjevclient/src/client/mod.rs +++ b/crates/tinyjevclient/src/client/mod.rs @@ -5,7 +5,7 @@ mod test; mod types; -pub use types::{Client, ClientConfig, EvaluationResult, RetryPolicy}; +pub use types::{Client, ClientConfig, EvaluationFailure, EvaluationResult, RetryPolicy}; use std::time::{Duration, Instant}; @@ -52,15 +52,28 @@ impl Client { /// Returns request validation, transport, HTTP, decoding, or response /// contract errors. Only transient transport failures, rate limits, and /// overload responses are retried. - pub async fn evaluate(&self, request: &EvaluationRequest) -> Result { - request.validate()?; + pub async fn evaluate( + &self, + request: &EvaluationRequest, + ) -> std::result::Result { let started = Instant::now(); + request.validate().map_err(|error| EvaluationFailure { + error, + attempts: 0, + latency: started.elapsed(), + })?; let mut attempts = 0_u32; loop { attempts = attempts.saturating_add(1); match self.send_once(request).await { Ok((response, request_id)) => { - response.validate_for(request)?; + response + .validate_for(request) + .map_err(|error| EvaluationFailure { + error, + attempts, + latency: started.elapsed(), + })?; return Ok(EvaluationResult { response, request_id, @@ -68,10 +81,20 @@ impl Client { latency: started.elapsed(), }); } - Err(Failure::Terminal(error)) => return Err(error), + Err(Failure::Terminal(error)) => { + return Err(EvaluationFailure { + error, + attempts, + latency: started.elapsed(), + }); + } Err(Failure::Retryable { error, retry_after }) => { if attempts > self.config.retry.max_retries { - return Err(error); + return Err(EvaluationFailure { + error, + attempts, + latency: started.elapsed(), + }); } let delay = retry_after.unwrap_or_else(|| self.config.retry.delay(attempts)); tokio::time::sleep(delay.min(self.config.retry.max_backoff)).await; @@ -129,6 +152,20 @@ impl ClientConfig { reason: "base URL must use HTTP or HTTPS".to_owned(), }); } + if url.scheme() == "http" + && !url + .host_str() + .and_then(|host| { + host.trim_matches(['[', ']']) + .parse::() + .ok() + }) + .is_some_and(|address| address.is_loopback()) + { + return Err(Error::InvalidConfig { + reason: "HTTP base URLs must use a literal loopback address".to_owned(), + }); + } if self.timeout.is_zero() { return Err(Error::InvalidConfig { reason: "timeout must be greater than zero".to_owned(), @@ -173,7 +210,7 @@ fn classify_status(status: StatusCode, retry_after: Option) -> Failure StatusCode::UNPROCESSABLE_ENTITY | StatusCode::BAD_REQUEST => { Failure::Terminal(Error::Unprocessable) } - StatusCode::TOO_MANY_REQUESTS => Failure::Retryable { + StatusCode::REQUEST_TIMEOUT | StatusCode::TOO_MANY_REQUESTS => Failure::Retryable { error: Error::RateLimited, retry_after, }, @@ -194,6 +231,19 @@ fn classify_status(status: StatusCode, retry_after: Option) -> Failure } fn parse_retry_after(value: Option<&reqwest::header::HeaderValue>) -> Option { - let seconds = value?.to_str().ok()?.parse::().ok()?; - Some(Duration::from_secs(seconds)) + parse_retry_after_at(value, std::time::SystemTime::now()) +} + +fn parse_retry_after_at( + value: Option<&reqwest::header::HeaderValue>, + now: std::time::SystemTime, +) -> Option { + let value = value?.to_str().ok()?; + if let Ok(seconds) = value.parse::() { + return Some(Duration::from_secs(seconds)); + } + httpdate::parse_http_date(value) + .ok()? + .duration_since(now) + .ok() } diff --git a/crates/tinyjevclient/src/client/test.rs b/crates/tinyjevclient/src/client/test.rs index 0d3f421..984bd01 100644 --- a/crates/tinyjevclient/src/client/test.rs +++ b/crates/tinyjevclient/src/client/test.rs @@ -2,7 +2,11 @@ #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] -use std::{collections::BTreeMap, sync::Arc, time::Duration}; +use std::{ + collections::BTreeMap, + sync::Arc, + time::{Duration, SystemTime}, +}; use serde_json::json; use tokio::{ @@ -141,7 +145,8 @@ async fn authentication_is_terminal() { .evaluate(&request()) .await .unwrap_err(); - assert!(matches!(error, Error::Authentication)); + assert!(matches!(error.error, Error::Authentication)); + assert_eq!(error.attempts, 1); assert_eq!(requests.lock().await.len(), 1); } @@ -153,7 +158,7 @@ async fn malformed_success_body_is_a_decode_failure() { .evaluate(&request()) .await .unwrap_err(); - assert!(matches!(error, Error::Decode { .. })); + assert!(matches!(error.error, Error::Decode { .. })); } #[tokio::test] @@ -176,6 +181,12 @@ fn rejects_invalid_configuration_before_transport() { Client::new(empty), Err(Error::InvalidConfig { .. }) )); + let mut invalid_url = ClientConfig::new("key"); + invalid_url.base_url = "not a URL".into(); + assert!(matches!( + Client::new(invalid_url), + Err(Error::InvalidConfig { .. }) + )); } #[test] @@ -191,6 +202,21 @@ fn validates_every_configuration_bound_and_redacted_key_replacement() { Err(Error::InvalidConfig { .. }) )); + for base_url in ["http://example.com", "http://localhost:8080"] { + let mut cleartext = ClientConfig::new("key"); + cleartext.base_url = base_url.into(); + assert!(matches!( + Client::new(cleartext), + Err(Error::InvalidConfig { .. }) + )); + } + let mut secure = ClientConfig::new("key"); + secure.base_url = "https://example.com".into(); + assert!(Client::new(secure).is_ok()); + let mut ipv6_loopback = ClientConfig::new("key"); + ipv6_loopback.base_url = "http://[::1]:8080".into(); + assert!(Client::new(ipv6_loopback).is_ok()); + let mut timeout = ClientConfig::new("key"); timeout.timeout = Duration::ZERO; assert!(matches!( @@ -235,6 +261,10 @@ fn status_classification_covers_terminal_and_retryable_classes() { .. } )); + assert!(matches!( + classify_status(StatusCode::REQUEST_TIMEOUT, None), + Failure::Retryable { .. } + )); assert!(matches!( classify_status(StatusCode::from_u16(529).unwrap(), None), Failure::Retryable { @@ -250,6 +280,14 @@ fn status_classification_covers_terminal_and_retryable_classes() { parse_retry_after(Some(&reqwest::header::HeaderValue::from_static("date"))), None ); + let now = SystemTime::UNIX_EPOCH + Duration::from_secs(1_000_000); + let future = now + Duration::from_secs(30); + let date = httpdate::fmt_http_date(future); + let header = reqwest::header::HeaderValue::from_str(&date).unwrap(); + assert_eq!( + parse_retry_after_at(Some(&header), now), + Some(Duration::from_secs(30)) + ); } #[tokio::test] @@ -261,7 +299,9 @@ async fn timeout_is_retryable_but_respects_the_attempt_bound() { .evaluate(&request()) .await .unwrap_err(); - assert!(matches!(error, Error::Timeout)); + assert!(matches!(error.error, Error::Timeout)); + assert_eq!(error.attempts, 1); + assert!(error.latency >= Duration::from_millis(5)); } #[tokio::test] @@ -272,5 +312,41 @@ async fn exhausted_rate_limit_returns_the_classified_error() { .evaluate(&request()) .await .unwrap_err(); - assert!(matches!(error, Error::RateLimited)); + assert!(matches!(error.error, Error::RateLimited)); + assert_eq!(error.attempts, 1); +} + +#[tokio::test] +async fn local_validation_and_response_validation_report_failure_metadata() { + let client = Client::new(config("http://127.0.0.1:1".into())).unwrap(); + let invalid = EvaluationRequest::jev("state", BTreeMap::new()); + let failure = client.evaluate(&invalid).await.unwrap_err(); + assert!(matches!(failure.error, Error::InvalidRequest { .. })); + assert_eq!(failure.attempts, 0); + + let body = json!({ + "model": "jev-latest", + "answers": {}, + "usage": {"input_tokens": 1, "output_tokens": 1} + }) + .to_string(); + let (base_url, _) = server(vec![response(200, &body, "")]).await; + let failure = Client::new(config(base_url)) + .unwrap() + .evaluate(&request()) + .await + .unwrap_err(); + assert!(matches!(failure.error, Error::InvalidResponse { .. })); + assert_eq!(failure.attempts, 1); +} + +#[tokio::test] +async fn connection_failure_is_classified_as_transport() { + let failure = Client::new(config("http://127.0.0.1:1".into())) + .unwrap() + .evaluate(&request()) + .await + .unwrap_err(); + assert!(matches!(failure.error, Error::Transport { .. })); + assert_eq!(failure.attempts, 1); } diff --git a/crates/tinyjevclient/src/client/types.rs b/crates/tinyjevclient/src/client/types.rs index b7c3e1f..0339068 100644 --- a/crates/tinyjevclient/src/client/types.rs +++ b/crates/tinyjevclient/src/client/types.rs @@ -2,7 +2,7 @@ use std::{fmt, time::Duration}; -use crate::EvaluationResponse; +use crate::{Error, EvaluationResponse}; /// Async `TypeSafe` System One client. #[derive(Clone)] @@ -113,3 +113,16 @@ pub struct EvaluationResult { /// End-to-end elapsed time including retry delays. pub latency: Duration, } + +/// A failed evaluation with the attempts and elapsed time it spent. +#[derive(Debug, thiserror::Error)] +#[error("{error}")] +pub struct EvaluationFailure { + /// Classified terminal failure. + #[source] + pub error: Error, + /// HTTP attempts made before failure; zero for local request validation. + pub attempts: u32, + /// End-to-end elapsed time including retry delays. + pub latency: Duration, +} diff --git a/crates/tinyjevclient/src/lib.rs b/crates/tinyjevclient/src/lib.rs index 3040ac8..f1adb0f 100644 --- a/crates/tinyjevclient/src/lib.rs +++ b/crates/tinyjevclient/src/lib.rs @@ -12,7 +12,7 @@ //! use serde_json::json; //! use tinyjevclient::{Choice, Client, EvaluationRequest, Question}; //! -//! # async fn example() -> tinyjevclient::Result<()> { +//! # async fn example() -> Result<(), Box> { //! let criteria = BTreeMap::from([ //! ("billing".to_owned(), Some(json!("payments and refunds"))), //! ("technical".to_owned(), Some(json!("bugs and outages"))), @@ -41,7 +41,7 @@ mod error; mod request; mod response; -pub use client::{Client, ClientConfig, EvaluationResult, RetryPolicy}; +pub use client::{Client, ClientConfig, EvaluationFailure, EvaluationResult, RetryPolicy}; pub use error::{Error, Result}; pub use request::{Choice, EvaluationRequest, Noul, NoulCriteria, Question, Score}; pub use response::{Answer, ChoiceAnswer, EvaluationResponse, NoulAnswer, ScoreAnswer, Usage}; diff --git a/crates/tinyjevclient/src/request/mod.rs b/crates/tinyjevclient/src/request/mod.rs index a035582..c04cf97 100644 --- a/crates/tinyjevclient/src/request/mod.rs +++ b/crates/tinyjevclient/src/request/mod.rs @@ -115,5 +115,10 @@ fn validate_instructions(value: &serde_json::Value) -> Result<()> { } fn is_empty_text(value: &serde_json::Value) -> bool { - matches!(value, serde_json::Value::String(text) if text.trim().is_empty()) + match value { + serde_json::Value::String(text) => text.trim().is_empty(), + serde_json::Value::Array(values) => values.is_empty(), + serde_json::Value::Object(values) => values.is_empty(), + _ => false, + } } diff --git a/crates/tinyjevclient/src/request/test.rs b/crates/tinyjevclient/src/request/test.rs index 2e1d822..c9b181a 100644 --- a/crates/tinyjevclient/src/request/test.rs +++ b/crates/tinyjevclient/src/request/test.rs @@ -142,6 +142,20 @@ fn rejects_blank_model_ids_instructions_and_criteria() { ); assert!(blank_choice.validate().is_err()); + for instructions in [json!({}), json!([])] { + let empty_structured = EvaluationRequest::jev( + "state", + BTreeMap::from([( + "choice".into(), + Question::Choice(Choice { + instructions, + criteria: BTreeMap::from([("a".into(), None), ("b".into(), None)]), + }), + )]), + ); + assert!(empty_structured.validate().is_err()); + } + let empty_option = EvaluationRequest::jev( "state", BTreeMap::from([( diff --git a/crates/tinyjevclient/src/response/mod.rs b/crates/tinyjevclient/src/response/mod.rs index fb7c1a5..81a80aa 100644 --- a/crates/tinyjevclient/src/response/mod.rs +++ b/crates/tinyjevclient/src/response/mod.rs @@ -73,7 +73,12 @@ fn validate_pair(question: &Question, answer: &Answer) -> Result<()> { .collect(); let actual: BTreeSet = answer.probabilities.keys().cloned().collect(); let legend: BTreeSet = answer.legend.keys().cloned().collect(); - if actual != expected || legend != expected { + let legend_matches = question + .criteria + .iter() + .enumerate() + .all(|(index, criterion)| answer.legend.get(&index.to_string()) == Some(criterion)); + if actual != expected || legend != expected || !legend_matches { return Err(Error::invalid_response( "score levels must exactly match request criteria", )); diff --git a/crates/tinyjevclient/src/response/test.rs b/crates/tinyjevclient/src/response/test.rs index f12f837..b842d8b 100644 --- a/crates/tinyjevclient/src/response/test.rs +++ b/crates/tinyjevclient/src/response/test.rs @@ -175,4 +175,12 @@ fn rejects_nonfinite_score_and_mismatched_legend() { }; score.legend.remove("1"); assert!(legend.validate_for(&request()).is_err()); + + let mut reversed = response(); + let Answer::Score(score) = reversed.answers.get_mut("quality").unwrap() else { + panic!("fixture answer should be a score") + }; + score.legend.insert("0".into(), json!("high")); + score.legend.insert("1".into(), json!("low")); + assert!(reversed.validate_for(&request()).is_err()); } diff --git a/docs/plans/system-one-client.md b/docs/plans/system-one-client.md index 0507392..7534e19 100644 --- a/docs/plans/system-one-client.md +++ b/docs/plans/system-one-client.md @@ -1,5 +1,7 @@ # Implement the System One client +Linked specification: [`../specs/system-one-client.md`](../specs/system-one-client.md). + 1. Replace the TinyBus template with one ordinary Rust library crate. 2. Define and pin Choice, Score, Noul, request, answer, usage, and response wires. 3. Validate requests and request-relative response invariants. diff --git a/docs/specs/system-one-client.md b/docs/specs/system-one-client.md index f7910f3..177a48e 100644 --- a/docs/specs/system-one-client.md +++ b/docs/specs/system-one-client.md @@ -17,7 +17,9 @@ own data. Authentication and request errors are terminal. Transport failures, timeouts, rate limits, overload, and server errors use a bounded caller-visible retry -policy. No retry is unbounded, and the result reports every attempt. +policy. No retry is unbounded, and success or failure reports every attempt and +the full elapsed time. HTTP is allowed only for literal loopback addresses; +every remote endpoint requires HTTPS. Credentials never appear in `Debug`, error messages, or retained response bodies. Application state and provider bodies are not logged by the crate. From 485c51a9e886322a85464fe8802a6e9c09cf16ce Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Thu, 17 Sep 2026 19:16:04 +0530 Subject: [PATCH 07/10] Bound retries and complete the client plan --- crates/tinyjevclient/src/client/mod.rs | 6 ++++++ crates/tinyjevclient/src/client/test.rs | 6 ++++++ docs/plans/system-one-client.md | 28 ++++++++++++++++++++----- 3 files changed, 35 insertions(+), 5 deletions(-) diff --git a/crates/tinyjevclient/src/client/mod.rs b/crates/tinyjevclient/src/client/mod.rs index 396c486..e48f45e 100644 --- a/crates/tinyjevclient/src/client/mod.rs +++ b/crates/tinyjevclient/src/client/mod.rs @@ -14,6 +14,7 @@ use reqwest::{StatusCode, header::RETRY_AFTER}; use crate::{Error, EvaluationRequest, EvaluationResponse, Result}; const SYSTEM_ONE_PATH: &str = "v1/systemone"; +const MAX_RETRIES: u32 = 100; impl Client { /// Construct a client from an explicit configuration. @@ -176,6 +177,11 @@ impl ClientConfig { reason: "retry backoffs must be greater than zero".to_owned(), }); } + if self.retry.max_retries > MAX_RETRIES { + return Err(Error::InvalidConfig { + reason: format!("max retries must not exceed {MAX_RETRIES}"), + }); + } Ok(()) } } diff --git a/crates/tinyjevclient/src/client/test.rs b/crates/tinyjevclient/src/client/test.rs index 984bd01..4d75b67 100644 --- a/crates/tinyjevclient/src/client/test.rs +++ b/crates/tinyjevclient/src/client/test.rs @@ -230,6 +230,12 @@ fn validates_every_configuration_bound_and_redacted_key_replacement() { Client::new(retry), Err(Error::InvalidConfig { .. }) )); + let mut unbounded = ClientConfig::new("key"); + unbounded.retry.max_retries = u32::MAX; + assert!(matches!( + Client::new(unbounded), + Err(Error::InvalidConfig { .. }) + )); } #[test] diff --git a/docs/plans/system-one-client.md b/docs/plans/system-one-client.md index 7534e19..a07f761 100644 --- a/docs/plans/system-one-client.md +++ b/docs/plans/system-one-client.md @@ -2,8 +2,26 @@ Linked specification: [`../specs/system-one-client.md`](../specs/system-one-client.md). -1. Replace the TinyBus template with one ordinary Rust library crate. -2. Define and pin Choice, Score, Noul, request, answer, usage, and response wires. -3. Validate requests and request-relative response invariants. -4. Add a rustls client with secret redaction, classified failures, and bounded retries. -5. Test all wire, validation, transport, retry, and public API behavior. +1. Replace the TinyBus template with the ordinary library at + `crates/tinyjevclient/`; remove the obsolete module crate, contract crate, + submodule, packaging workflow, and release documentation. +2. Define and pin Choice, Score, Noul, request, answer, usage, and response + wires under `crates/tinyjevclient/src/{request,response}/`. +3. Validate requests and request-relative response invariants in those module + roots, with every case in their adjacent `test.rs` files. +4. Implement the rustls client, secret redaction, classified failures, failure + measurements, HTTPS policy, and bounded retries under + `crates/tinyjevclient/src/{client,error}/`. +5. Update the crate example, public API test, root documentation, CI, lockfile, + dependency policy, and environment example in the same change. +6. Verify with: + + ```sh + cargo fmt --all -- --check + cargo clippy --all-targets --all-features -- -D warnings + cargo build --all-targets --all-features + cargo test --all-features + RUSTDOCFLAGS="-D warnings" cargo doc --no-deps --all-features + .github/scripts/check-file-coverage.sh 90 coverage.json + cargo deny check all + ``` From 09abfcbddce63698239d5d92ef79a924dea51812 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Thu, 17 Sep 2026 19:17:01 +0530 Subject: [PATCH 08/10] Clarify bounded transport retries --- crates/tinyjevclient/src/client/README.md | 5 ++++- crates/tinyjevclient/src/client/mod.rs | 6 ++++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/crates/tinyjevclient/src/client/README.md b/crates/tinyjevclient/src/client/README.md index 65d1644..e146256 100644 --- a/crates/tinyjevclient/src/client/README.md +++ b/crates/tinyjevclient/src/client/README.md @@ -7,4 +7,7 @@ remain private and render only as `[REDACTED]`. Production endpoints require HTTPS. Plain HTTP is accepted only for literal loopback IP addresses used by local tests and development services. Both -successful and failed evaluations report attempts and end-to-end latency. +successful and failed evaluations report attempts and end-to-end latency. All +transport failures use the same explicit bounded retry policy because the +transport error taxonomy cannot reliably distinguish transient DNS, TLS, and +connectivity failures from permanent ones. diff --git a/crates/tinyjevclient/src/client/mod.rs b/crates/tinyjevclient/src/client/mod.rs index e48f45e..1845204 100644 --- a/crates/tinyjevclient/src/client/mod.rs +++ b/crates/tinyjevclient/src/client/mod.rs @@ -51,8 +51,10 @@ impl Client { /// # Errors /// /// Returns request validation, transport, HTTP, decoding, or response - /// contract errors. Only transient transport failures, rate limits, and - /// overload responses are retried. + /// contract errors. All transport failures are retried within the explicit + /// attempt cap because the transport does not reliably distinguish a + /// transient DNS/TLS/connectivity fault from a permanent one. HTTP request + /// validation and authentication failures remain terminal. pub async fn evaluate( &self, request: &EvaluationRequest, From 2eff8dfa8e4389ef0f22c614469524740ffb0dc0 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Thu, 17 Sep 2026 19:18:25 +0530 Subject: [PATCH 09/10] Classify timeouts and reject URL credentials --- crates/tinyjevclient/src/client/mod.rs | 11 ++++++++++- crates/tinyjevclient/src/client/test.rs | 11 ++++++++++- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/crates/tinyjevclient/src/client/mod.rs b/crates/tinyjevclient/src/client/mod.rs index 1845204..72dd951 100644 --- a/crates/tinyjevclient/src/client/mod.rs +++ b/crates/tinyjevclient/src/client/mod.rs @@ -155,6 +155,11 @@ impl ClientConfig { reason: "base URL must use HTTP or HTTPS".to_owned(), }); } + if !url.username().is_empty() || url.password().is_some() { + return Err(Error::InvalidConfig { + reason: "base URL must not contain credentials".to_owned(), + }); + } if url.scheme() == "http" && !url .host_str() @@ -218,7 +223,11 @@ fn classify_status(status: StatusCode, retry_after: Option) -> Failure StatusCode::UNPROCESSABLE_ENTITY | StatusCode::BAD_REQUEST => { Failure::Terminal(Error::Unprocessable) } - StatusCode::REQUEST_TIMEOUT | StatusCode::TOO_MANY_REQUESTS => Failure::Retryable { + StatusCode::REQUEST_TIMEOUT => Failure::Retryable { + error: Error::Timeout, + retry_after, + }, + StatusCode::TOO_MANY_REQUESTS => Failure::Retryable { error: Error::RateLimited, retry_after, }, diff --git a/crates/tinyjevclient/src/client/test.rs b/crates/tinyjevclient/src/client/test.rs index 4d75b67..21d54d4 100644 --- a/crates/tinyjevclient/src/client/test.rs +++ b/crates/tinyjevclient/src/client/test.rs @@ -216,6 +216,12 @@ fn validates_every_configuration_bound_and_redacted_key_replacement() { let mut ipv6_loopback = ClientConfig::new("key"); ipv6_loopback.base_url = "http://[::1]:8080".into(); assert!(Client::new(ipv6_loopback).is_ok()); + let mut userinfo = ClientConfig::new("key"); + userinfo.base_url = "https://user:password@example.com".into(); + assert!(matches!( + Client::new(userinfo), + Err(Error::InvalidConfig { .. }) + )); let mut timeout = ClientConfig::new("key"); timeout.timeout = Duration::ZERO; @@ -269,7 +275,10 @@ fn status_classification_covers_terminal_and_retryable_classes() { )); assert!(matches!( classify_status(StatusCode::REQUEST_TIMEOUT, None), - Failure::Retryable { .. } + Failure::Retryable { + error: Error::Timeout, + .. + } )); assert!(matches!( classify_status(StatusCode::from_u16(529).unwrap(), None), From 0781308762057615f42e7a4b2bde1053dd5c12bc Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Thu, 17 Sep 2026 19:25:41 +0530 Subject: [PATCH 10/10] Allow documented Score rounding --- crates/tinyjevclient/src/response/mod.rs | 3 ++- crates/tinyjevclient/src/response/test.rs | 6 ++++++ docs/specs/system-one-client.md | 4 +++- 3 files changed, 11 insertions(+), 2 deletions(-) diff --git a/crates/tinyjevclient/src/response/mod.rs b/crates/tinyjevclient/src/response/mod.rs index 81a80aa..30a03e3 100644 --- a/crates/tinyjevclient/src/response/mod.rs +++ b/crates/tinyjevclient/src/response/mod.rs @@ -12,6 +12,7 @@ use std::collections::BTreeSet; use crate::{Error, EvaluationRequest, Question, Result}; const PROBABILITY_TOLERANCE: f64 = 0.000_001; +const SCORE_TOLERANCE: f64 = 0.02; impl EvaluationResponse { /// Check this response against the request that produced it. @@ -91,7 +92,7 @@ fn validate_pair(question: &Question, answer: &Answer) -> Result<()> { .iter() .map(|(level, probability)| level.parse::().unwrap_or_default() * probability) .sum(); - if (answer.score - expected_score).abs() > PROBABILITY_TOLERANCE { + if (answer.score - expected_score).abs() > SCORE_TOLERANCE { return Err(Error::invalid_response( "score must equal the probability-weighted level", )); diff --git a/crates/tinyjevclient/src/response/test.rs b/crates/tinyjevclient/src/response/test.rs index b842d8b..58d90c8 100644 --- a/crates/tinyjevclient/src/response/test.rs +++ b/crates/tinyjevclient/src/response/test.rs @@ -65,6 +65,12 @@ fn response() -> EvaluationResponse { #[test] fn validates_all_three_answer_types() { response().validate_for(&request()).unwrap(); + let mut rounded = response(); + let Answer::Score(answer) = rounded.answers.get_mut("quality").unwrap() else { + panic!("fixture answer should be a score") + }; + answer.score = 0.81; + rounded.validate_for(&request()).unwrap(); } #[test] diff --git a/docs/specs/system-one-client.md b/docs/specs/system-one-client.md index 177a48e..8bb6e90 100644 --- a/docs/specs/system-one-client.md +++ b/docs/specs/system-one-client.md @@ -9,7 +9,9 @@ levels, and Noul may describe its true and false criteria. The client validates request bounds before transport and validates response ids, answer types, probability ranges and sums, selected maxima, Score legends, and -weighted Score values before returning. Typed output is an interface guarantee, +weighted Score values before returning. Score consistency allows two hundredths +for provider display rounding while probability sums retain strict tolerance. +Typed output is an interface guarantee, not a truth guarantee; applications evaluate accuracy and thresholds on their own data.