diff --git a/.ci/default.yaml b/.ci/default.yaml index c6b7702fc3..d6165464b4 100644 --- a/.ci/default.yaml +++ b/.ci/default.yaml @@ -3,6 +3,11 @@ to: predev on_green: merge deadline: 6h +# The PR battery, named explicitly so a check declared below but NOT listed here is on-demand only +# (`modernci call measure {repo, from, check}`), never run on every move. +on: + move: [preflight, gates, clippy, compile, test, doctest, deletion-matrix, deny] + manual: [oracle, test-gated] env: RUSTFLAGS: "-D warnings" CARGO_TERM_COLOR: never @@ -44,6 +49,13 @@ checks: run: | meta=$(cargo metadata --format-version 1 --locked) cargo build --locked $(echo "$meta" | jq -r '.workspace_members as $m | .packages[] | select(.id as $i | $m | index($i)) | select(any(.targets[]; .kind | index("cdylib"))) | "-p " + .name') + # The example cdylibs the tests dlopen, built BY NAME and always: the loader's (the neutral frame + # door and the other door fixtures, which busbar's and the connector's tests load too) and + # busbar's test plane. A run scoped to `busbar` alone builds no other package's examples, and a + # test whose fixture is absent fails rather than skips; no variable tells this step the + # affected set, so the build does not wait on one. + cargo build --locked -p busbar-plugin-loader --examples + cargo build --locked -p busbar --examples plugin() { man=$(echo "$meta" | jq -r --arg p "$1" 'first(.packages[] | select(.name == $p) | .manifest_path) // empty') if [ -z "$man" ]; then [ "$3" = optional ] && return 0; echo "busbar resolves no $1"; return 1; fi @@ -71,6 +83,44 @@ checks: # The selftests run the gates over the real tree, merge-base included. requires: [git-history] needs: [gates] + test-gated: + # The feature-gated suites the `test` check never builds (row 164): `test` runs default features + # only. A, the busbar/kernel/core-admin gates at once (test-harness, llm-on-driver, + # transport-webrtc, openapi-schema, timing), filtered to the gated binaries and modules so the + # default suites do not run twice; B, the plugin-pack CLI (pack); C, the loom models in release. + run: | + meta=$(cargo metadata --format-version 1 --locked) + cargo build --locked $(echo "$meta" | jq -r '.workspace_members as $m | .packages[] | select(.id as $i | $m | index($i)) | select(any(.targets[]; .kind | index("cdylib"))) | "-p " + .name') + # The example cdylibs the tests dlopen, built BY NAME and always: the loader's (the neutral frame + # door and the other door fixtures, which busbar's and the connector's tests load too) and + # busbar's test plane. A run scoped to `busbar` alone builds no other package's examples, and a + # test whose fixture is absent fails rather than skips; no variable tells this step the + # affected set, so the build does not wait on one. + cargo build --locked -p busbar-plugin-loader --examples + cargo build --locked -p busbar --examples + plugin() { + man=$(echo "$meta" | jq -r --arg p "$1" 'first(.packages[] | select(.name == $p) | .manifest_path) // empty') + if [ -z "$man" ]; then [ "$3" = optional ] && return 0; echo "busbar resolves no $1"; return 1; fi + ws="$(dirname "$man")/Cargo.toml" + pkgs=$(cargo metadata --format-version 1 --no-deps --manifest-path "$ws" | jq -r '.packages[].name') + if ! printf '%s\n' $pkgs | grep -qx "$2"; then [ "$3" = optional ] && return 0; echo "$1 has no $2"; return 1; fi + if [ "$3" = dropped-in ] && cargo metadata --format-version 1 --no-deps --manifest-path "$ws" | jq -e --arg p "$2" '.packages[] | select(.name == $p) | .features | has("dropped-in")' >/dev/null; then + cargo build --locked --manifest-path "$ws" -p "$2" --features dropped-in --target-dir "$PWD/target" + else + cargo build --locked --manifest-path "$ws" -p "$2" --target-dir "$PWD/target" + fi + } + plugin busbar-transport-tcp-plugin busbar-transport-tcp-plugin dropped-in + plugin busbar-transport-http busbar-transport-http-plugin dropped-in + plugin busbar-secret-env busbar-secret-env-plugin optional + plugin busbar-plane-decisions busbar-plane-decisions-plugin optional + cargo build --workspace --locked + cargo nextest run --locked --no-fail-fast -p busbar -p busbar-kernel -p busbar-core-admin --features busbar/test-harness,busbar/llm-on-driver,busbar/transport-webrtc,busbar/openapi-schema,busbar-kernel/timing -E 'binary(mcp_stdio_serve) | binary(hook_parity_driver) | binary(admin_cross_plane) | binary(timing_smoke) | package(busbar-core-admin) | (package(busbar-kernel) & test(/timing::/)) | (package(busbar) & test(/(serve_tests|secret_source|connector|boot)::/))' + cargo nextest run --locked --no-fail-fast -p busbar-plugin-loader --features pack -E 'binary(/busbar-plugin-pack/)' + timeout 3000 cargo test --release --locked -p busbar-kernel --features loom-model txn_loom + baseline: target + requires: [git-history] + needs: [gates] doctest: run: cargo test --workspace --doc --locked --no-fail-fast scope: affected @@ -88,3 +138,24 @@ checks: baseline: target requires: [cargo-deny] needs: [preflight] + # ON-DEMAND ONLY (not in on.move): the predev->dev oracle, measurable on a PR head that changes the + # served LLM path (e.g. a plane default flip), so claim C3 is proven before landing, not only at the move. + oracle: + # busbar's own check tool, the pinned busbar-oracle (the backend's busbar-oracle tool sets + # BUSBAR_ORACLE_RUST_BIN): the judge is the one dev's oracle-rust.pin names, run only when its + # sha256 is that pin's for this host (a candidate never picks its own judge); the 1.5.5 golden is + # add-only from dev to this commit; then the candidate's release binary is recorded over the + # corpus and replayed against the committed 1.5.5 golden, strict: green is every cell byte-green + # or forgiven by an owner-signed accepted difference. + run: | + pin=$(git show "$MODERNCI_TO_SHA:testing/shadow-oracle/oracle-rust.pin") + want=$(printf '%s\n' "$pin" | sed -n "s/^sha256_linux_$(uname -m)=//p") + [[ "$want" =~ ^[0-9a-f]{64}$ ]] || { echo "ORACLE-PIN: oracle-rust.pin at $MODERNCI_TO_SHA pins no linux $(uname -m) judge"; exit 1; } + echo "$want $BUSBAR_ORACLE_RUST_BIN" | sha256sum -c - + "$BUSBAR_ORACLE_RUST_BIN" golden-guard --repo . --base "$MODERNCI_TO_SHA" --head HEAD + cargo build --locked --release -p busbar --bin busbar + ./bin/oracle fetch-golden + rm -rf target/oracle/recordings/candidate target/oracle/reports/dev + ./bin/oracle record --bin target/release/busbar --plane all --out target/oracle/recordings/candidate + ./bin/oracle replay --golden testing/shadow-oracle/golden/1.5.5 --candidate target/oracle/recordings/candidate --out target/oracle/reports/dev --strict + needs: [gates] diff --git a/.ci/predev.yaml b/.ci/predev.yaml index 966fa5be98..8dea412464 100644 --- a/.ci/predev.yaml +++ b/.ci/predev.yaml @@ -1,42 +1,52 @@ -# What predev must pass to move into dev: busbar-release's `dev` rung (crates/busbar-release-cli/ -# ladder.json: gates inherit, oracle, conformance; verdict absolute), the 1.6.0 done-oracle, and the -# Q128 architecture audit (`cargo xtask q128 audit`). ModernCI names a branch file for the branch -# being moved, so the move predev -> dev reads this file. Shadow while busbar-release still decides. +# What predev must pass to move into dev: the `dev` rung (gates, oracle, conformance), the 1.6.0 +# done-oracle, and the Q128 architecture audit (`cargo xtask q128 audit`). ModernCI names a branch +# file for the branch being moved, so the move predev -> dev reads this file, and ModernCI alone +# judges it: promote.yml, the busbar-release hop that judged it before, is deleted (OWNER +# 2026-10-12, "ModernCI only"; docs/design/BUSBAR-1.6.0.md Part 6, "BUSBAR-RELEASE RETIRED FROM +# BUSBAR"). Its green landing is DEV-GREEN. to: dev on_green: merge # The owner's approval on the pull request holds the merge too; q128-audit gates the move on the # audit's record for the commit (no open BLOCK). approve: true -deadline: 6h +# 8h: plane-delete --all (40-90 min), two LTO release builds and the workspace tests do not fit in 6h. +deadline: 8h env: RUSTFLAGS: "-D warnings" CARGO_TERM_COLOR: never CARGO_PROFILE_DEV_DEBUG: "0" CARGO_INCREMENTAL: "0" on: - move: [gates, oracle, conformance, verify-done, q128-audit] + move: [gates, oracle, conformance, verify-done, q128-audit, test-gated] checks: gates: - # The rung inherits predev's boarded gates; here they are re-run on the commit, no worse than - # dev, as the predev file runs them. + # The rung re-runs every registered gate on the commit, ABSOLUTE (no `baseline`: every row + # green, not "no worse than dev"; dev holds no xtask and no ledgers to be measured against). + # The base-relative rules (new edges, minted rows) take predev's own last landed commit as their + # base: every lane move already judged its delta against predev, so dev is never the base. run: | - export XTASK_CEILING_BASE="$MODERNCI_TO_SHA" XTASK_SHIP_TARGET="$MODERNCI_TO" + export XTASK_CEILING_BASE="$(git rev-parse "$MODERNCI_HEAD~1")" XTASK_SHIP_TARGET="$MODERNCI_TO" cargo xtask gate --all --format=tsv - baseline: target - rules: ["qa/*.toml"] + # The base-relative rules read history: only a backend with the repo's history runs this check. + requires: [git-history] oracle: - # busbar's own check tool, the pinned busbar-oracle (the backend's busbar-oracle tool sets - # BUSBAR_ORACLE_RUST_BIN): the judge is the one dev's oracle-rust.pin names, run only when its - # sha256 is that pin's for this host (a candidate never picks its own judge); the 1.5.5 golden is - # add-only from dev to this commit; then the candidate's release binary is recorded over the - # corpus and replayed against the committed 1.5.5 golden, strict: green is every cell byte-green - # or forgiven by an owner-signed accepted difference. + # busbar's own judge, obtained by bin/oracle itself (OWNER 2026-10-13: no hand-installed tools; + # #911): on Linux it fetches busbar-release's published busbar-oracle for the BASE pin's + # release_ref ($MODERNCI_TO_SHA's testing/shadow-oracle/oracle-rust.pin, or this tree's when dev + # holds none: the first move) and runs it only at that pin's sha256 for this architecture, its + # .sig verified against busbar-release's release key; no line for this architecture is no judge. + # BUSBAR_RELEASE_TOKEN (a secret ModernCI gives this check) reads the private release. The 1.5.5 + # golden is add-only from dev to this commit wherever dev holds it (none before the first move); + # then the candidate's release binary is recorded over the corpus and replayed against the + # committed 1.5.5 golden, strict: green is every cell byte-green or forgiven by an owner-signed + # accepted difference. run: | - pin=$(git show "$MODERNCI_TO_SHA:testing/shadow-oracle/oracle-rust.pin") - want=$(printf '%s\n' "$pin" | sed -n "s/^sha256_linux_$(uname -m)=//p") - [[ "$want" =~ ^[0-9a-f]{64}$ ]] || { echo "ORACLE-PIN: oracle-rust.pin at $MODERNCI_TO_SHA pins no linux $(uname -m) judge"; exit 1; } - echo "$want $BUSBAR_ORACLE_RUST_BIN" | sha256sum -c - - "$BUSBAR_ORACLE_RUST_BIN" golden-guard --repo . --base "$MODERNCI_TO_SHA" --head HEAD + ./bin/oracle tool-dir >/dev/null + if git cat-file -e "$MODERNCI_TO_SHA:testing/shadow-oracle/golden/1.5.5" 2>/dev/null; then + ./bin/oracle golden-guard --repo . --base "$MODERNCI_TO_SHA" --head HEAD + else + echo "golden-guard: dev ($MODERNCI_TO_SHA) holds no golden/1.5.5; nothing to hold add-only" + fi cargo build --locked --release -p busbar --bin busbar ./bin/oracle fetch-golden rm -rf target/oracle/recordings/candidate target/oracle/reports/dev @@ -87,9 +97,12 @@ checks: # backend probed to have all of these, and says which one is missing otherwise. requires: [docker, docker-compose, python3-venv, curl, git] verify-done: - # The done-oracle, full run: exit 0 only (3 is a --fast, provisional answer). + # The done-oracle, full run: exit 0 only (3 is a --fast, provisional answer). Its PARITY group + # obtains the judge through bin/oracle as the oracle check does (BUSBAR_RELEASE_TOKEN). run: bash scripts/verify-1.6.0-done.sh needs: [gates] + # It reads merge-bases and tags (the config-schema baseline is tag v1.5.3): full history and tags. + requires: [git-history] q128-audit: # DEV-GREEN item 5 (docs/design/BUSBAR-1.6.0.md l.2910-2912; 1.6.0-QUESTIONS.md Q128, option # A): one fresh, independent architecture audit on this commit with no open BLOCK, and a clean @@ -99,3 +112,44 @@ checks: # tree, or none, is a BLOCK. run: cargo xtask q128 audit needs: [gates] + # The audit is judged against this commit's history: full history and tags. + requires: [git-history] + test-gated: + # The feature-gated suites the `test` check never builds (row 164): `test` runs default features + # only. A, the busbar/kernel/core-admin gates at once (test-harness, llm-on-driver, + # transport-webrtc, openapi-schema, timing), filtered to the gated binaries and modules so the + # default suites do not run twice; B, the plugin-pack CLI (pack); C, the loom models in release. + run: | + meta=$(cargo metadata --format-version 1 --locked) + cargo build --locked $(echo "$meta" | jq -r '.workspace_members as $m | .packages[] | select(.id as $i | $m | index($i)) | select(any(.targets[]; .kind | index("cdylib"))) | "-p " + .name') + # The example cdylibs the tests dlopen, built BY NAME and always: the loader's (the neutral frame + # door and the other door fixtures, which busbar's and the connector's tests load too) and + # busbar's test plane. A run scoped to `busbar` alone builds no other package's examples, and a + # test whose fixture is absent fails rather than skips; no variable tells this step the + # affected set, so the build does not wait on one. + cargo build --locked -p busbar-plugin-loader --examples + cargo build --locked -p busbar --examples + plugin() { + man=$(echo "$meta" | jq -r --arg p "$1" 'first(.packages[] | select(.name == $p) | .manifest_path) // empty') + if [ -z "$man" ]; then [ "$3" = optional ] && return 0; echo "busbar resolves no $1"; return 1; fi + ws="$(dirname "$man")/Cargo.toml" + pkgs=$(cargo metadata --format-version 1 --no-deps --manifest-path "$ws" | jq -r '.packages[].name') + if ! printf '%s\n' $pkgs | grep -qx "$2"; then [ "$3" = optional ] && return 0; echo "$1 has no $2"; return 1; fi + if [ "$3" = dropped-in ] && cargo metadata --format-version 1 --no-deps --manifest-path "$ws" | jq -e --arg p "$2" '.packages[] | select(.name == $p) | .features | has("dropped-in")' >/dev/null; then + cargo build --locked --manifest-path "$ws" -p "$2" --features dropped-in --target-dir "$PWD/target" + else + cargo build --locked --manifest-path "$ws" -p "$2" --target-dir "$PWD/target" + fi + } + plugin busbar-transport-tcp-plugin busbar-transport-tcp-plugin dropped-in + plugin busbar-transport-http busbar-transport-http-plugin dropped-in + plugin busbar-secret-env busbar-secret-env-plugin optional + plugin busbar-plane-decisions busbar-plane-decisions-plugin optional + cargo build --workspace --locked + cargo nextest run --locked --no-fail-fast -p busbar -p busbar-kernel -p busbar-core-admin --features busbar/test-harness,busbar/llm-on-driver,busbar/transport-webrtc,busbar/openapi-schema,busbar-kernel/timing -E 'binary(mcp_stdio_serve) | binary(hook_parity_driver) | binary(admin_cross_plane) | binary(timing_smoke) | package(busbar-core-admin) | (package(busbar-kernel) & test(/timing::/)) | (package(busbar) & test(/(serve_tests|secret_source|connector|boot)::/))' + cargo nextest run --locked --no-fail-fast -p busbar-plugin-loader --features pack -E 'binary(/busbar-plugin-pack/)' + timeout 3000 cargo test --release --locked -p busbar-kernel --features loom-model txn_loom + # Absolute at this rung, as every check of this move (no `baseline`: dev holds no suites to be + # measured against). + requires: [git-history] + needs: [gates] diff --git a/.github/required-status-checks.md b/.github/required-status-checks.md index ffea47b689..530e300a00 100644 --- a/.github/required-status-checks.md +++ b/.github/required-status-checks.md @@ -5,22 +5,18 @@ Branch protection lives in repository rulesets. No script writes it. | Branch | Ruleset | Required | | --- | --- | --- | | `predev` | 24945926 | ModernCI's `ci` check. ModernCI judges the PR (`.ci/default.yaml`) and lands it. | -| `dev` | 24340358 | `preflight` and `hop`, both from `.github/workflows/promote.yml` (the predev to dev promotion). | +| `dev` | 24340358 | ModernCI's `ci` check on the predev to dev move (`.ci/predev.yaml`). The ruleset still names `preflight` and `hop`, the jobs of the deleted `promote.yml`: the owner replaces them with `ci`. | | `qa`, `main` | 24334334 | Owner-only; no status check stands in for the owner. | -Use the exact **check-run name** (the job's `name:`), not the workflow name. In `promote.yml`, `hop` -is the job id with no `name:`, so its context reads `hop`. - -* `preflight`: formatting, `Cargo.lock` current, the generated C header current, on a free runner. -* `hop`: the pinned busbar-release engine plans the hop and admits the candidate (every registered - `cargo xtask gate`, plus the oracle and conformance where the rung asks for them). +Use the exact **check name** the judge posts. ModernCI posts one check, `ci`, per pull request head. ## Reusable workflows (plugin repos call these) `plugin-ci.yml`, `plugin-consumer-verify.yml`, `plugin-repin.yml` and `plugin-release.yml` are `workflow_call` only, called by the plugin repos' rendered callers (busbar-release `template/`, `busbar-release plugin sync`). Their status bubbles up into the **caller's** job, so require the -caller's job in the plugin repo, never these directly. +caller's job in the plugin repo, never these directly. They stay until each plugin repo is +registered with ModernCI and judged by its own branch files. ## Branch hygiene diff --git a/.github/workflows/promote.yml b/.github/workflows/promote.yml deleted file mode 100644 index aa1c9b0ae5..0000000000 --- a/.github/workflows/promote.yml +++ /dev/null @@ -1,474 +0,0 @@ -name: Promote -# This workflow serves the predev->dev promotion pull request and the dev->qa->main ladder above it. The -# hop is GetBusbar/busbar-release's `busbar-release ci hop|verdict|publish|fallback` at RELEASE_REF -# (private, so no `uses:`); these jobs only check out both repos and run it. No job here runs BOARD -# (`ci train promote` needs the train's write token, and the job that runs the candidate's code holds -# none): a PR lands by merge. -# Pull requests into predev are not judged here: ModernCI judges them (required check `ci`) and lands -# them with its own merge train, so predev has no merge queue and this workflow runs for no pull request -# into predev or into a lane branch (a stacked lane PR), nor for `merge_group`. The `merge_group` handling in the jobs below -# is left in place, dormant: it is job logic, and the concurrency and env lines the preflight holds to busbar-release's compiled copy read it. -on: { pull_request: { branches: [dev, qa, main] } } -# R5 (busbar-release gate.rs, OWNER 2026-10-08): no run is ever cancelled in progress. A pull request -# has at most one run running and one pending, and each newer push replaces the pending one, so the -# pending run is always the newest head. A merge-queue run is its own group (a cancelled queue run -# re-runs whole, owner money 2026-10-03). The preflight holds this line to the one compiled into -# busbar-release: any drift is RED. -concurrency: { group: "promote-${{ github.event.pull_request.number || github.event.merge_group.head_ref }}", cancel-in-progress: false } -permissions: { contents: read } -# THE ENGINE PIN IS NOT IN THIS FILE (OWNER 2026-10-10): the org variables ENGINE_REF (a full -# busbar-release commit) and ENGINE_SHA256 (the sha256 of that commit's dev-bin ci asset), set by -# hand with `busbar-release engine pin `, so an engine update never waits in predev's merge -# queue. The preflight reads them ONCE and hands them to every later job as outputs: one run is one -# engine even if the variables move mid-run. Unset or malformed is a hard FAIL (ENGINE-PIN), never -# an older pin; the bytes run only when their sha256 is ENGINE_SHA256 (workflow-rules R16). -env: - # The engine contract (busbar-release contract.rs): 2 = the hop holds only the read-only R2 keys and `cache-publish` writes. An engine refuses a contract it does not support, and the auto-pin moves only to engines that support this number. - BUSBAR_RELEASE_CONTRACT: "2" - SHA: ${{ github.event.pull_request.head.sha || github.event.merge_group.head_sha }} - TO: ${{ github.base_ref || github.event.merge_group.base_ref }} - BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.merge_group.base_sha }} -jobs: - preflight: - if: github.event_name == 'merge_group' || github.event.pull_request.head.repo.full_name == github.repository - runs-on: ubuntu-latest - timeout-minutes: 15 - # THE RUN GATE reads GitHub (open pull requests, the merge queue, this head's promote runs) with - # this job's token. This job runs the candidate's `cargo xtask`, so the token only READS - # (busbar-release 941028a): the gate's writes (ORPHANS, R7 REPEAT) are recorded in the act - # artifact before any candidate code runs, and `preflight-act` below makes them. - # OLD-ENGINE SHIM (remove once ENGINE_REF is at or past busbar-release 941028a): an older engine's - # R6 field `jobs.preflight.permissions (actions: read)` reads this line's text, comment included; - # this job's token stays contents read only. - permissions: { contents: read } # actions: read is public on this repo; the writes are preflight-act's - # RUNNERS: the runner gateway's table (vars.RUNNERS), from which the preflight sizes `runner=`. - env: { RELEASE_REF: "${{ vars.ENGINE_REF }}", ENGINE_SHA256: "${{ vars.ENGINE_SHA256 }}", RUNNERS: "${{ vars.RUNNERS }}" } - outputs: { engine_ref: "${{ steps.engine.outputs.engine_ref }}", engine_sha256: "${{ steps.engine.outputs.engine_sha256 }}", runner: "${{ steps.pre.outputs.runner }}", soak: "${{ steps.pre.outputs.soak }}", one_runner: "${{ steps.pre.outputs.one_runner }}", needs_release_artifact: "${{ steps.pre.outputs.needs_release_artifact }}", run: "${{ steps.pre.outputs.run }}", reason: "${{ steps.pre.outputs.reason }}" } - steps: - # Full history: the pre-flight reads the pull request's diff against BASE_SHA to choose one runner or the matrix. - - { uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1, with: { ref: "${{ env.SHA }}", path: busbar, fetch-depth: 0 } } # v7 - # THE ENGINE, downloaded, never compiled: CI's --no-default-features binary of RELEASE_REF from - # busbar-release's dev-bin release (dev-bin.yml), run only when its sha256 is ENGINE_SHA256. - # First the pin's shape: a full 40-hex commit and a 64-hex digest, or FAIL. - - name: Engine (busbar-release at RELEASE_REF, sha256-pinned) - id: engine - env: { GH_TOKEN: "${{ secrets.TURNSTILE_DISPATCH_TOKEN }}" } - run: | - set -euo pipefail - [[ "$RELEASE_REF" =~ ^[0-9a-f]{40}$ ]] || { echo "::error title=ENGINE-PIN FAIL::vars.ENGINE_REF is unset or not a full 40-hex busbar-release commit (busbar-release engine pin )"; exit 1; } - [[ "$ENGINE_SHA256" =~ ^[0-9a-f]{64}$ ]] || { echo "::error title=ENGINE-PIN FAIL::vars.ENGINE_SHA256 is unset or not a 64-hex sha256 (busbar-release engine pin )"; exit 1; } - api=https://api.github.com/repos/GetBusbar/busbar-release/releases - name="busbar-release-ci-linux-x86_64-$RELEASE_REF" - id=$(curl -fsSL -H "Authorization: Bearer $GH_TOKEN" "$api/tags/dev-bin" | jq -r --arg n "$name" '.assets[] | select(.name == $n) | .id') - [ -n "$id" ] || { echo "::error::dev-bin has no $name"; exit 1; } - curl -fsSL -H "Authorization: Bearer $GH_TOKEN" -H "Accept: application/octet-stream" -o "$RUNNER_TEMP/busbar-release" "$api/assets/$id" - echo "$ENGINE_SHA256 $RUNNER_TEMP/busbar-release" | sha256sum -c - - chmod +x "$RUNNER_TEMP/busbar-release" - { echo "engine_ref=$RELEASE_REF"; echo "engine_sha256=$ENGINE_SHA256"; } >> "$GITHUB_OUTPUT" - - { uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1, with: { repository: GetBusbar/busbar-release, ref: "${{ env.RELEASE_REF }}", token: "${{ secrets.TURNSTILE_DISPATCH_TOKEN }}", path: busbar-release, persist-credentials: false } } # v7 - # The pre-flight is busbar-release's Rust CLI (preflight.json is the one list): it sizes the hop - # (runner= to $GITHUB_OUTPUT), chooses how a pull request into predev runs (one_runner=: ONE runner - # when the crates its diff affects are estimated within 20 min, else the shard matrix; OWNER-LOCKED - # 2026-10-06), whether this run reads the release artifact (needs_release_artifact=: the rung runs - # the oracle or conformance; predev runs neither), and runs every pre-flight check against the - # candidate. Built with no default features, as the CLI documents for this job. FIRST it is THE - # RUN GATE (OWNER 2026-10-08): run=1|0 and reason= from facts only (R1 STACK, R2 queued, R3 - # superseded, R4 duplicate head, R5/R6 this file's wiring); run=0 means nothing in this run - # builds, and a RED rule fails here. The gate's writes are recorded (`--act-out act/act.json`, - # before any candidate code runs) for preflight-act; this job makes none. - # OLD-ENGINE SHIM (remove once ENGINE_REF is at or past 941028a): an engine without --act-out - # runs without it. - - id: pre - working-directory: busbar-release - env: { GITHUB_TOKEN: "${{ github.token }}", RUNNERS: "${{ vars.RUNNERS }}" } - run: | - act=() - if "$RUNNER_TEMP/busbar-release" ci preflight --help | grep -q -- --act-out; then act=(--act-out "$GITHUB_WORKSPACE/act/act.json"); fi - "$RUNNER_TEMP/busbar-release" ci preflight "${act[@]}" - - { if: always(), uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a, with: { name: "preflight-act-${{ env.SHA }}", path: "act/", retention-days: 1, if-no-files-found: ignore, overwrite: true } } # v7.0.1 - # THE RUN GATE'S WRITES (busbar-release 941028a), in a job that runs NO candidate code: it checks - # out nothing, downloads the engine (sha256-pinned) and the preflight's act artifact, and runs - # `ci preflight-act`, which re-reads GitHub itself: ORPHANS cancels the merge-group runs a queue - # rebuild left running (actions: write), and R7 REPEAT turns off the auto-merge of a PR whose head - # already failed the queue as its own red (pull-requests: write), only the PR both the artifact - # and the re-read name. Merge groups only: a pull request has nothing to act on. It runs whatever - # the preflight concluded (R7 is a RED preflight), and never decides the verdict. - preflight-act: - needs: preflight - if: ${{ always() && github.event_name == 'merge_group' && needs.preflight.outputs.engine_ref != '' }} - runs-on: ubuntu-latest - timeout-minutes: 10 - permissions: { contents: read, actions: write, pull-requests: write } - env: { RELEASE_REF: "${{ needs.preflight.outputs.engine_ref }}", ENGINE_SHA256: "${{ needs.preflight.outputs.engine_sha256 }}" } - steps: - # THE ENGINE, downloaded, never compiled: CI's --no-default-features binary of RELEASE_REF from - # busbar-release's dev-bin release (dev-bin.yml), run only when its sha256 is ENGINE_SHA256. - # First the pin's shape: a full 40-hex commit and a 64-hex digest, or FAIL. - - name: Engine (busbar-release at RELEASE_REF, sha256-pinned) - env: { GH_TOKEN: "${{ secrets.TURNSTILE_DISPATCH_TOKEN }}" } - run: | - set -euo pipefail - [[ "$RELEASE_REF" =~ ^[0-9a-f]{40}$ ]] || { echo "::error title=ENGINE-PIN FAIL::vars.ENGINE_REF is unset or not a full 40-hex busbar-release commit (busbar-release engine pin )"; exit 1; } - [[ "$ENGINE_SHA256" =~ ^[0-9a-f]{64}$ ]] || { echo "::error title=ENGINE-PIN FAIL::vars.ENGINE_SHA256 is unset or not a 64-hex sha256 (busbar-release engine pin )"; exit 1; } - api=https://api.github.com/repos/GetBusbar/busbar-release/releases - name="busbar-release-ci-linux-x86_64-$RELEASE_REF" - id=$(curl -fsSL -H "Authorization: Bearer $GH_TOKEN" "$api/tags/dev-bin" | jq -r --arg n "$name" '.assets[] | select(.name == $n) | .id') - [ -n "$id" ] || { echo "::error::dev-bin has no $name"; exit 1; } - curl -fsSL -H "Authorization: Bearer $GH_TOKEN" -H "Accept: application/octet-stream" -o "$RUNNER_TEMP/busbar-release" "$api/assets/$id" - echo "$ENGINE_SHA256 $RUNNER_TEMP/busbar-release" | sha256sum -c - - chmod +x "$RUNNER_TEMP/busbar-release" - - { continue-on-error: true, uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c, with: { name: "preflight-act-${{ env.SHA }}", path: act } } # v8.0.1 - # OLD-ENGINE SHIM (remove once ENGINE_REF is at or past 941028a): an engine without the verb - # made these writes in the preflight itself. - - name: The run gate's writes (ORPHANS, R7 REPEAT) - env: { GITHUB_TOKEN: "${{ github.token }}" } - run: | - if ! "$RUNNER_TEMP/busbar-release" ci --help | grep -q preflight-act; then echo "engine $RELEASE_REF has no preflight-act: nothing to act on here"; exit 0; fi - "$RUNNER_TEMP/busbar-release" ci preflight-act --act "$GITHUB_WORKSPACE/act/act.json" - # The release artifact the oracle records and the slsa-verifier conformance rig judges. It runs in - # every merge group (the fold requires it: a check on the release build), and in every run whose - # rung reads the artifact (`needs_release_artifact=true`: the rung runs the oracle or conformance, - # i.e. the predev->dev pull request and up). dev moves by pull request now, not by the ladder - # fast-forwarding a merge group's sha (ARCHITECT 2026-10-02, superseded), so the run that judges a - # promotion builds the artifact of exactly the commit it promotes: its shards download - # `slsa-artifact-` from THIS run, which a merge-group-only build never produced (#740 failed - # every run at that download). workflow-rules R18 holds this wiring. Its SLSA provenance is minted - # from `base64-subjects` by the pinned slsa-github-generator once the owner rules on R14 (the - # generator must be referenced by tag); until then the suite is not-run, a standing red here. - slsa-build: - if: needs.preflight.outputs.run == '1' && (github.event_name == 'merge_group' || needs.preflight.outputs.needs_release_artifact == 'true') - needs: preflight - permissions: { contents: read } - runs-on: ${{ needs.preflight.outputs.runner }} - timeout-minutes: 90 - outputs: { base64-subjects: "${{ steps.digest.outputs.base64-subjects }}" } - steps: - - { uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1, with: { ref: "${{ env.SHA }}", path: busbar, persist-credentials: false } } # v7 - - name: Build the release artifact - working-directory: busbar - run: | - cargo build --locked --release -p busbar --bin busbar - mkdir -p ../slsa && cp target/release/busbar ../slsa/busbar - - id: digest - name: Its sha256 (the provenance subject) - working-directory: slsa - run: | - sha256sum busbar > busbar.sha256 - echo "base64-subjects=$(base64 -w0 busbar.sha256)" >> "$GITHUB_OUTPUT" - - { uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a, with: { name: "slsa-artifact-${{ env.SHA }}", path: "slsa/", retention-days: 7, if-no-files-found: error } } # v7.0.1 - # THE QA SOAK (OWNER 2026-10-10; busbar #734): the soak stays, as a 1-hour stability gate on each - # qa promotion. `busbar-release soak ` (profile.rs's full profile: 3600 s, 50 rps, - # 16 concurrent) soaks THIS candidate's release binary on the runner the preflight chose, wherever - # the ladder's entry asks for it (`soak=true`: qa). The hop folds this job's result like - # slsa-build's (`ci turnstile merge --needs soak=...`): a failed soak DENIES the promotion. CI=false - # selects the full hour (the CI context would pick the two-minute smoke). It runs the candidate, so - # it holds no write token. The candidate binary is this run's slsa-build artifact (a rung with the - # soak reads the artifact, so slsa-build runs); a run without it builds it here. The engine is the - # downloaded ci binary (built with `--features soak`, dev-bin.yml), sha256-pinned like every other - # job's. - soak: - needs: [preflight, slsa-build] - if: ${{ !cancelled() && needs.preflight.result == 'success' && needs.preflight.outputs.run == '1' && needs.preflight.outputs.soak == 'true' }} - permissions: { contents: read } - runs-on: ${{ needs.preflight.outputs.runner }} - timeout-minutes: 180 - env: { RELEASE_REF: "${{ needs.preflight.outputs.engine_ref }}", ENGINE_SHA256: "${{ needs.preflight.outputs.engine_sha256 }}" } - steps: - - { uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1, with: { ref: "${{ env.SHA }}", path: busbar, persist-credentials: false } } # v7 - # THE ENGINE, downloaded, never compiled: CI's --no-default-features binary of RELEASE_REF from - # busbar-release's dev-bin release (dev-bin.yml), run only when its sha256 is ENGINE_SHA256. - # First the pin's shape: a full 40-hex commit and a 64-hex digest, or FAIL. - - name: Engine (busbar-release at RELEASE_REF, sha256-pinned) - env: { GH_TOKEN: "${{ secrets.TURNSTILE_DISPATCH_TOKEN }}" } - run: | - set -euo pipefail - [[ "$RELEASE_REF" =~ ^[0-9a-f]{40}$ ]] || { echo "::error title=ENGINE-PIN FAIL::vars.ENGINE_REF is unset or not a full 40-hex busbar-release commit (busbar-release engine pin )"; exit 1; } - [[ "$ENGINE_SHA256" =~ ^[0-9a-f]{64}$ ]] || { echo "::error title=ENGINE-PIN FAIL::vars.ENGINE_SHA256 is unset or not a 64-hex sha256 (busbar-release engine pin )"; exit 1; } - api=https://api.github.com/repos/GetBusbar/busbar-release/releases - name="busbar-release-ci-linux-x86_64-$RELEASE_REF" - id=$(curl -fsSL -H "Authorization: Bearer $GH_TOKEN" "$api/tags/dev-bin" | jq -r --arg n "$name" '.assets[] | select(.name == $n) | .id') - [ -n "$id" ] || { echo "::error::dev-bin has no $name"; exit 1; } - curl -fsSL -H "Authorization: Bearer $GH_TOKEN" -H "Accept: application/octet-stream" -o "$RUNNER_TEMP/busbar-release" "$api/assets/$id" - echo "$ENGINE_SHA256 $RUNNER_TEMP/busbar-release" | sha256sum -c - - chmod +x "$RUNNER_TEMP/busbar-release" - - { uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1, with: { repository: GetBusbar/busbar-release, ref: "${{ env.RELEASE_REF }}", token: "${{ secrets.TURNSTILE_DISPATCH_TOKEN }}", path: busbar-release, persist-credentials: false } } # v7 - - if: needs.slsa-build.result == 'success' - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 - with: { name: "slsa-artifact-${{ env.SHA }}", path: slsa } - - if: needs.slsa-build.result != 'success' - working-directory: busbar - run: | - cargo build --locked --release -p busbar --bin busbar - mkdir -p ../slsa && cp target/release/busbar ../slsa/busbar - - name: Soak the candidate (the full profile) - working-directory: busbar-release - env: { CI: "false", GH_TOKEN: "${{ github.token }}", TMPDIR: "${{ runner.temp }}" } - run: | - chmod +x "$GITHUB_WORKSPACE/slsa/busbar" - "$RUNNER_TEMP/busbar-release" soak "$GITHUB_WORKSPACE/slsa/busbar" - - { if: always(), uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a, with: { name: "soak-${{ env.SHA }}", path: "${{ runner.temp }}/busbar-soak-*/", retention-days: 30, if-no-files-found: warn } } # v7.0.1 - # The merge group's hop, and the predev->dev pull request's, is SHARDED (ARCHITECT 2026-10-02): four - # runners each judge one part (`--shard K/4`: test:workspace's hash partition K, plus the unsplit steps - # dealt to K; the gates in shard 1, the oracle and conformance in shard 4). Each builds and runs the - # candidate's code, so it holds NO write token (contents and actions read only, no persisted - # credentials); its report leaves as an artifact. It starts beside slsa-build, not after it (B3-P, - # 2026-10-08), and no shard waits for it: the fold requires slsa-build's result (`needs:`, - # `--needs`, `--must-run`), and nothing a shard runs reads the release artifact (the slsa-verifier - # leg is not-run until `conformance record` is handed `--slsa-artifact`). The predev->dev shards - # sat ~7 min each in `await-job` for a download nobody read (2026-10-11). Every shard runs to - # the bottom (FULLY SEEN, busbar-release #152). A pull request into predev runs the shards only when the - # pre-flight chose the matrix (one_runner=false: every crate affected, or over 20 min on one runner); - # otherwise its hop is ONE runner, in the `hop` job below. - hop-shard: - name: "hop shard ${{ matrix.shard }}" - strategy: { fail-fast: false, matrix: { shard: [1, 2, 3, 4] } } - needs: [preflight] - if: ${{ !cancelled() && needs.preflight.result == 'success' && needs.preflight.outputs.run == '1' && (github.event_name == 'merge_group' || github.base_ref == 'dev' || needs.preflight.outputs.one_runner != 'true') }} - permissions: { contents: read, actions: read } - runs-on: ${{ needs.preflight.outputs.runner }} - timeout-minutes: 330 - env: { RELEASE_REF: "${{ needs.preflight.outputs.engine_ref }}", ENGINE_SHA256: "${{ needs.preflight.outputs.engine_sha256 }}", RUNNERS: "${{ vars.RUNNERS }}" } - steps: - - { uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1, with: { ref: "${{ env.SHA }}", path: busbar, fetch-depth: 0, persist-credentials: false } } # v7 - # THE ENGINE, downloaded, never compiled: CI's --no-default-features binary of RELEASE_REF from - # busbar-release's dev-bin release (dev-bin.yml), run only when its sha256 is ENGINE_SHA256. - # First the pin's shape: a full 40-hex commit and a 64-hex digest, or FAIL. - - name: Engine (busbar-release at RELEASE_REF, sha256-pinned) - env: { GH_TOKEN: "${{ secrets.TURNSTILE_DISPATCH_TOKEN }}" } - run: | - set -euo pipefail - [[ "$RELEASE_REF" =~ ^[0-9a-f]{40}$ ]] || { echo "::error title=ENGINE-PIN FAIL::vars.ENGINE_REF is unset or not a full 40-hex busbar-release commit (busbar-release engine pin )"; exit 1; } - [[ "$ENGINE_SHA256" =~ ^[0-9a-f]{64}$ ]] || { echo "::error title=ENGINE-PIN FAIL::vars.ENGINE_SHA256 is unset or not a 64-hex sha256 (busbar-release engine pin )"; exit 1; } - api=https://api.github.com/repos/GetBusbar/busbar-release/releases - name="busbar-release-ci-linux-x86_64-$RELEASE_REF" - id=$(curl -fsSL -H "Authorization: Bearer $GH_TOKEN" "$api/tags/dev-bin" | jq -r --arg n "$name" '.assets[] | select(.name == $n) | .id') - [ -n "$id" ] || { echo "::error::dev-bin has no $name"; exit 1; } - curl -fsSL -H "Authorization: Bearer $GH_TOKEN" -H "Accept: application/octet-stream" -o "$RUNNER_TEMP/busbar-release" "$api/assets/$id" - echo "$ENGINE_SHA256 $RUNNER_TEMP/busbar-release" | sha256sum -c - - chmod +x "$RUNNER_TEMP/busbar-release" - # THE JUDGE, pinned in busbar (OWNER 2026-10-10): the busbar-oracle testing/shadow-oracle/oracle-rust.pin - # names, read at the BASE (a candidate never picks its own judge), downloaded from busbar-release's - # dev-bin release and run only when its sha256 is the pin's sha256_linux_x86_64. The engine pin - # never moves it. Only the last shard runs the oracle; `ci hop` and bin/oracle both take the binary - # from BUSBAR_ORACLE_RUST_BIN, so nothing builds a judge. - - name: Oracle (pinned at the base, sha256-checked) - if: matrix.shard == 4 - env: { GH_TOKEN: "${{ secrets.TURNSTILE_DISPATCH_TOKEN }}" } - run: | - set -euo pipefail - pin="$(git -C busbar show "$BASE_SHA:testing/shadow-oracle/oracle-rust.pin" 2>/dev/null || true)" - # A base from before the pinned binary (no sha256 line): the oracle is built as before. - grep -q '^sha256_linux_x86_64=' <<<"$pin" || { echo "::notice::oracle-rust.pin at $BASE_SHA pins no binary: bin/oracle builds the judge"; exit 0; } - ref="$(printf '%s\n' "$pin" | sed -n 's/^release_ref=//p')" - ORACLE_SHA256="$(printf '%s\n' "$pin" | sed -n 's/^sha256_linux_x86_64=//p')" - [[ "$ref" =~ ^[0-9a-f]{40}$ && "$ORACLE_SHA256" =~ ^[0-9a-f]{64}$ ]] || { echo "::error title=ORACLE-PIN FAIL::oracle-rust.pin at $BASE_SHA needs a 40-hex release_ref and a 64-hex sha256_linux_x86_64"; exit 1; } - api=https://api.github.com/repos/GetBusbar/busbar-release/releases - name="busbar-oracle-linux-x86_64-$ref" - id=$(curl -fsSL -H "Authorization: Bearer $GH_TOKEN" "$api/tags/dev-bin" | jq -r --arg n "$name" '.assets[] | select(.name == $n) | .id') - [ -n "$id" ] || { echo "::error title=ORACLE-PIN FAIL::dev-bin has no $name"; exit 1; } - curl -fsSL -H "Authorization: Bearer $GH_TOKEN" -H "Accept: application/octet-stream" -o "$RUNNER_TEMP/busbar-oracle" "$api/assets/$id" - echo "$ORACLE_SHA256 $RUNNER_TEMP/busbar-oracle" | sha256sum -c - - chmod +x "$RUNNER_TEMP/busbar-oracle" - echo "BUSBAR_ORACLE_RUST_BIN=$RUNNER_TEMP/busbar-oracle" >> "$GITHUB_ENV" - - { uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1, with: { repository: GetBusbar/busbar-release, ref: "${{ env.RELEASE_REF }}", token: "${{ secrets.TURNSTILE_DISPATCH_TOKEN }}", path: busbar-release, persist-credentials: false } } # v7 - # The conformance rigs `conformance record` runs in the full plan need node 22 and pnpm 10 (the mcp - # suite's official runner, and rolldown's native binding built under it); the Latchkey large image - # carries node 20, so mcp read not-run (CONFORMANCE-RIGS, 2026-10-02). Only the last shard of a - # rung that runs conformance or the oracle (needs_release_artifact) records them; the others - # skip the ~15 s install. - - { if: "matrix.shard == 4 && needs.preflight.outputs.needs_release_artifact == 'true'", uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413, with: { version: 10 } } # v6.1.0 - - { if: "matrix.shard == 4 && needs.preflight.outputs.needs_release_artifact == 'true'", uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020, with: { node-version: 22 } } # v7.0.0 - # No debuginfo in the dev/test builds: build_test's all-features, feature-matrix and test builds plus - # the oracle's release build filled the runner's ~41 GiB free disk with debuginfo (ENOSPC, no log). - # Base and candidate build alike, so the no-worse-than-base verdict is unchanged. - # GITHUB_TOKEN (contents read) is for `ci hop`'s R3 re-check alone (is this merge group still a - # current queue entry's head?), which runs before any candidate code; the judge that runs the - # candidate never holds it. A superseded group's shard builds nothing. - # THE R2 KEYS HERE ONLY READ (busbar-release 069f032): this job runs candidate code, and the hop - # refuses to start (INFRA) with the write keys in its environment. Unset read-only keys fail - # open (no cache). The judge's cache puts land in r2-outbox/, uploaded below and published by - # `cache-publish`, the one job that holds the write keys (workflow-rules R19). - - working-directory: busbar-release - run: $RUNNER_TEMP/busbar-release ci hop - env: { GITHUB_TOKEN: "${{ github.token }}", R2_RO_KEY_ID: "${{ secrets.R2_CACHE_RO_ACCESS_KEY_ID }}", R2_RO_KEY_SECRET: "${{ secrets.R2_CACHE_RO_SECRET_ACCESS_KEY }}", CARGO_PROFILE_DEV_DEBUG: "0", SHARD: "${{ matrix.shard }}/4" } - - { if: always(), uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a, with: { name: "turnstile-report-${{ env.SHA }}-shard${{ matrix.shard }}", path: "artifacts/", if-no-files-found: warn } } # v7.0.1 - - { if: always(), uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a, with: { name: "r2-outbox-${{ env.SHA }}-shard${{ matrix.shard }}", path: "r2-outbox/", retention-days: 1, if-no-files-found: ignore, overwrite: true } } # v7.0.1 - # `hop` (the required check) is the hop's ONE verdict. In a merge group and on the predev->dev pull - # request it folds the shards: BOARD only when every shard reported and boarded and slsa-build is - # green (or skipped: a pull request whose rung reads no release artifact): a red slsa-build denies - # it; a shard that is missing, timed out or INFRA makes it INFRA, never green. A pull request - # INTO PREDEV whose pre-flight - # chose one runner (OWNER-LOCKED 2026-10-06, BUSBAR-1.6.0 Part 6 "Verification runs once per input") - # runs it as ONE runner: no shard, no slsa-build; the ladder's predev `enter_pr`, the merge group's - # gates and steps scoped to the crates the PR changes plus their reverse dependencies, on the PR merged - # onto the base tip, judged no worse than the base the merge group landed in its 4 shards - # (BASE_SHARDS). It runs the candidate's code, so it holds no write token either. - hop: - needs: [preflight, slsa-build, hop-shard, soak] - if: ${{ !cancelled() && needs.preflight.result == 'success' }} - permissions: { contents: read, actions: read } - runs-on: ${{ (github.event_name == 'pull_request' && github.base_ref != 'dev' && needs.preflight.outputs.one_runner == 'true') && needs.preflight.outputs.runner || 'ubuntu-latest' }} - timeout-minutes: ${{ (github.event_name == 'pull_request' && github.base_ref != 'dev' && needs.preflight.outputs.one_runner == 'true') && 330 || 30 }} - env: { ONE_RUNNER: "${{ github.event_name == 'pull_request' && github.base_ref != 'dev' && needs.preflight.outputs.one_runner == 'true' }}", RELEASE_REF: "${{ needs.preflight.outputs.engine_ref }}", ENGINE_SHA256: "${{ needs.preflight.outputs.engine_sha256 }}", RUNNERS: "${{ vars.RUNNERS }}" } - steps: - # THE RUN GATE said run=0 (neutral: the merge queue, or another run of this head, judges it). - # Nothing was built, so this run does NOT JUDGE the head: the required check fails here, never - # passes by skipping (a skipped required check counts as success and would land an unjudged - # merge group). The preflight writes one_runner=false on run=0, so nothing below builds either. - - if: needs.preflight.outputs.run != '1' - env: { REASON: "${{ needs.preflight.outputs.reason }}" } - run: | - echo "::error title=NOT JUDGED::not judged by this run (run=0): $REASON. Not a verdict on the code." - exit 1 - - if: env.ONE_RUNNER == 'true' - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - with: { ref: "${{ env.SHA }}", path: busbar, fetch-depth: 0, persist-credentials: false } - # THE ENGINE, downloaded, never compiled: CI's --no-default-features binary of RELEASE_REF from - # busbar-release's dev-bin release (dev-bin.yml), run only when its sha256 is ENGINE_SHA256. - # First the pin's shape: a full 40-hex commit and a 64-hex digest, or FAIL. - - name: Engine (busbar-release at RELEASE_REF, sha256-pinned) - env: { GH_TOKEN: "${{ secrets.TURNSTILE_DISPATCH_TOKEN }}" } - run: | - set -euo pipefail - [[ "$RELEASE_REF" =~ ^[0-9a-f]{40}$ ]] || { echo "::error title=ENGINE-PIN FAIL::vars.ENGINE_REF is unset or not a full 40-hex busbar-release commit (busbar-release engine pin )"; exit 1; } - [[ "$ENGINE_SHA256" =~ ^[0-9a-f]{64}$ ]] || { echo "::error title=ENGINE-PIN FAIL::vars.ENGINE_SHA256 is unset or not a 64-hex sha256 (busbar-release engine pin )"; exit 1; } - api=https://api.github.com/repos/GetBusbar/busbar-release/releases - name="busbar-release-ci-linux-x86_64-$RELEASE_REF" - id=$(curl -fsSL -H "Authorization: Bearer $GH_TOKEN" "$api/tags/dev-bin" | jq -r --arg n "$name" '.assets[] | select(.name == $n) | .id') - [ -n "$id" ] || { echo "::error::dev-bin has no $name"; exit 1; } - curl -fsSL -H "Authorization: Bearer $GH_TOKEN" -H "Accept: application/octet-stream" -o "$RUNNER_TEMP/busbar-release" "$api/assets/$id" - echo "$ENGINE_SHA256 $RUNNER_TEMP/busbar-release" | sha256sum -c - - chmod +x "$RUNNER_TEMP/busbar-release" - - { uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1, with: { repository: GetBusbar/busbar-release, ref: "${{ env.RELEASE_REF }}", token: "${{ secrets.TURNSTILE_DISPATCH_TOKEN }}", path: busbar-release, persist-credentials: false } } # v7 - # No debuginfo in the dev/test builds (as in the shards: the runner's disk). Read-only R2 keys, - # the puts to r2-outbox/ for `cache-publish` (as in the shards: the judge runs candidate code). - - if: env.ONE_RUNNER == 'true' - working-directory: busbar-release - run: $RUNNER_TEMP/busbar-release ci hop - env: { R2_RO_KEY_ID: "${{ secrets.R2_CACHE_RO_ACCESS_KEY_ID }}", R2_RO_KEY_SECRET: "${{ secrets.R2_CACHE_RO_SECRET_ACCESS_KEY }}", CARGO_PROFILE_DEV_DEBUG: "0", BASE_SHARDS: "4" } - - if: always() && env.ONE_RUNNER == 'true' - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: { name: "turnstile-report-${{ env.SHA }}", path: "artifacts/", if-no-files-found: warn } - - if: always() && env.ONE_RUNNER == 'true' - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: { name: "r2-outbox-${{ env.SHA }}-one", path: "r2-outbox/", retention-days: 1, if-no-files-found: ignore, overwrite: true } - - if: env.ONE_RUNNER != 'true' - continue-on-error: true - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 - with: { pattern: "turnstile-report-${{ env.SHA }}-shard*", path: shards } - - name: Fold the shards - if: env.ONE_RUNNER != 'true' - env: { GH_TOKEN: "${{ github.token }}", SLSA_BUILD: "${{ needs.slsa-build.result }}", SOAK: "${{ needs.soak.result }}", SLSA_READ: "${{ github.event_name == 'merge_group' || needs.preflight.outputs.needs_release_artifact == 'true' }}", SOAK_READ: "${{ needs.preflight.outputs.soak }}" } - run: | - mkdir -p merge artifacts - # A SKIPPED needed job DENIES (busbar-release 03cb349) unless the rung does not read it: - # slsa-build is read in a merge group and wherever needs_release_artifact, the soak where - # the ladder asks for it (soak=true). OLD-ENGINE SHIM (remove once ENGINE_REF is at or past - # 03cb349): an engine without --must-run takes a skip as neutral itself. - must=() - if "$RUNNER_TEMP/busbar-release" ci turnstile merge --help | grep -q -- --must-run; then - must=(--must-run "slsa-build=$SLSA_READ" --must-run "soak=$SOAK_READ") - fi - # The run's own job list: a shard with no report is INFRA:platform when its job was never - # created (GitHub dropped it, as in the 2026-10-07 16:52-17:01Z Actions outage), else - # INFRA:ours. A list that cannot be read leaves plain INFRA; the hop is never green on it. - gh api "repos/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID/attempts/$GITHUB_RUN_ATTEMPT/jobs?per_page=100" \ - >"$GITHUB_WORKSPACE/shard-jobs.json" || true - for k in 1 2 3 4; do - d="shards/turnstile-report-$SHA-shard$k" - mkdir -p "merge/shard-$k" "artifacts/shard-$k" - [ -f "$d/turnstile-report.json" ] && cp "$d/turnstile-report.json" "merge/shard-$k/" - [ -d "$d" ] && cp -r "$d/." "artifacts/shard-$k/" - done - cd busbar-release - "$RUNNER_TEMP/busbar-release" \ - ci turnstile merge --sha "$SHA" --shards 4 --dir "$GITHUB_WORKSPACE/merge" --out "$GITHUB_WORKSPACE/artifacts/turnstile-report.json" \ - --shard-jobs "$GITHUB_WORKSPACE/shard-jobs.json" --needs "slsa-build=$SLSA_BUILD" --needs "soak=$SOAK" "${must[@]}" - # The durable verdict (verdict/), from the folded report and every shard's step logs: the artifact publish reads. - - if: always() && needs.preflight.outputs.run == '1' - working-directory: busbar-release - run: $RUNNER_TEMP/busbar-release ci verdict - - { if: "always() && needs.preflight.outputs.run == '1'", uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a, with: { name: "verdict-${{ env.SHA }}", path: "verdict/", retention-days: 90, if-no-files-found: warn } } # v7.0.1 - # THE R2 CACHE'S ONE WRITER (busbar-release 069f032). The hop jobs judge with READ-ONLY keys and - # leave their cache puts (base reports, step PASSes) in r2-outbox/ artifacts; this job uploads them - # with the WRITE keys. It runs NO candidate code: it checks out nothing, downloads the engine - # (sha256-pinned) and the outboxes, and runs `ci cache-publish`, which uploads only objects under - # the cache's own prefixes. It is no part of the verdict: nothing needs it, it is no required - # check, and it fails open (no keys, no outbox, a failed upload: a later cache miss). - # workflow-rules R19: no job that runs candidate code names the write keys. - cache-publish: - needs: [preflight, hop-shard, hop] - if: ${{ always() && needs.preflight.result == 'success' && needs.preflight.outputs.run == '1' }} - runs-on: ubuntu-latest - timeout-minutes: 15 - continue-on-error: true - permissions: { contents: read } - env: { RELEASE_REF: "${{ needs.preflight.outputs.engine_ref }}", ENGINE_SHA256: "${{ needs.preflight.outputs.engine_sha256 }}" } - steps: - # THE ENGINE, downloaded, never compiled: CI's --no-default-features binary of RELEASE_REF from - # busbar-release's dev-bin release (dev-bin.yml), run only when its sha256 is ENGINE_SHA256. - # First the pin's shape: a full 40-hex commit and a 64-hex digest, or FAIL. - - name: Engine (busbar-release at RELEASE_REF, sha256-pinned) - env: { GH_TOKEN: "${{ secrets.TURNSTILE_DISPATCH_TOKEN }}" } - run: | - set -euo pipefail - [[ "$RELEASE_REF" =~ ^[0-9a-f]{40}$ ]] || { echo "::error title=ENGINE-PIN FAIL::vars.ENGINE_REF is unset or not a full 40-hex busbar-release commit (busbar-release engine pin )"; exit 1; } - [[ "$ENGINE_SHA256" =~ ^[0-9a-f]{64}$ ]] || { echo "::error title=ENGINE-PIN FAIL::vars.ENGINE_SHA256 is unset or not a 64-hex sha256 (busbar-release engine pin )"; exit 1; } - api=https://api.github.com/repos/GetBusbar/busbar-release/releases - name="busbar-release-ci-linux-x86_64-$RELEASE_REF" - id=$(curl -fsSL -H "Authorization: Bearer $GH_TOKEN" "$api/tags/dev-bin" | jq -r --arg n "$name" '.assets[] | select(.name == $n) | .id') - [ -n "$id" ] || { echo "::error::dev-bin has no $name"; exit 1; } - curl -fsSL -H "Authorization: Bearer $GH_TOKEN" -H "Accept: application/octet-stream" -o "$RUNNER_TEMP/busbar-release" "$api/assets/$id" - echo "$ENGINE_SHA256 $RUNNER_TEMP/busbar-release" | sha256sum -c - - chmod +x "$RUNNER_TEMP/busbar-release" - - { continue-on-error: true, uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c, with: { pattern: "r2-outbox-${{ env.SHA }}-*", path: r2-outbox, merge-multiple: true } } # v8.0.1 - # OLD-ENGINE SHIM (remove once ENGINE_REF is at or past 069f032): an engine without the verb - # wrote the cache from the hop itself. - - name: Publish the hop's cache puts (the R2 write keys, in this job only) - env: { R2_KEY_ID: "${{ secrets.R2_CACHE_ACCESS_KEY_ID }}", R2_KEY_SECRET: "${{ secrets.R2_CACHE_SECRET_ACCESS_KEY }}" } - run: | - if ! "$RUNNER_TEMP/busbar-release" ci --help | grep -q cache-publish; then echo "engine $RELEASE_REF has no cache-publish: nothing published"; exit 0; fi - "$RUNNER_TEMP/busbar-release" ci cache-publish --outbox "$GITHUB_WORKSPACE/r2-outbox" - # A job with a write token (as is preflight-act). It never checks out the candidate: busbar-release at RELEASE_REF, - # and the hop's verdict artifact. The sticky PR comment and the branch health issue (`ci publish`); a - # hop whose runner was lost or timed out wrote no verdict, so `ci fallback` records the job and step - # conclusions the API keeps after the logs are purged, and the run gate's reason when it refused the - # run (STACK, wiring drift). A run the gate held neutral (run=0, preflight green) posts nothing: the - # queue or another run judges that head. - publish: - needs: [preflight, hop] - if: always() && needs.preflight.result != 'skipped' && (needs.preflight.result != 'success' || needs.preflight.outputs.run == '1') - runs-on: ubuntu-latest - timeout-minutes: 10 - # statuses: write: a merge group DENIED as its PR's own red (first in the group, or behind a green - # group) marks that PR's head red - # ("merge queue" status) beside its sticky verdict, so the watcher's fix agent starts at failure. - permissions: { contents: read, actions: read, checks: read, pull-requests: write, statuses: write } - env: { RELEASE_REF: "${{ needs.preflight.outputs.engine_ref }}", ENGINE_SHA256: "${{ needs.preflight.outputs.engine_sha256 }}" } - steps: - # THE ENGINE, downloaded, never compiled: CI's --no-default-features binary of RELEASE_REF from - # busbar-release's dev-bin release (dev-bin.yml), run only when its sha256 is ENGINE_SHA256. - # First the pin's shape: a full 40-hex commit and a 64-hex digest, or FAIL. - - name: Engine (busbar-release at RELEASE_REF, sha256-pinned) - env: { GH_TOKEN: "${{ secrets.TURNSTILE_DISPATCH_TOKEN }}" } - run: | - set -euo pipefail - [[ "$RELEASE_REF" =~ ^[0-9a-f]{40}$ ]] || { echo "::error title=ENGINE-PIN FAIL::vars.ENGINE_REF is unset or not a full 40-hex busbar-release commit (busbar-release engine pin )"; exit 1; } - [[ "$ENGINE_SHA256" =~ ^[0-9a-f]{64}$ ]] || { echo "::error title=ENGINE-PIN FAIL::vars.ENGINE_SHA256 is unset or not a 64-hex sha256 (busbar-release engine pin )"; exit 1; } - api=https://api.github.com/repos/GetBusbar/busbar-release/releases - name="busbar-release-ci-linux-x86_64-$RELEASE_REF" - id=$(curl -fsSL -H "Authorization: Bearer $GH_TOKEN" "$api/tags/dev-bin" | jq -r --arg n "$name" '.assets[] | select(.name == $n) | .id') - [ -n "$id" ] || { echo "::error::dev-bin has no $name"; exit 1; } - curl -fsSL -H "Authorization: Bearer $GH_TOKEN" -H "Accept: application/octet-stream" -o "$RUNNER_TEMP/busbar-release" "$api/assets/$id" - echo "$ENGINE_SHA256 $RUNNER_TEMP/busbar-release" | sha256sum -c - - chmod +x "$RUNNER_TEMP/busbar-release" - - { uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1, with: { repository: GetBusbar/busbar-release, ref: "${{ env.RELEASE_REF }}", token: "${{ secrets.TURNSTILE_DISPATCH_TOKEN }}", path: busbar-release, persist-credentials: false } } # v7 - - { id: verdict, continue-on-error: true, uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c, with: { name: "verdict-${{ env.SHA }}", path: verdict } } # v8.0.1 - - if: steps.verdict.outcome == 'success' - working-directory: busbar-release - run: $RUNNER_TEMP/busbar-release ci publish - env: { GH_TOKEN: "${{ github.token }}", HEALTH_BRANCHES: none } - - if: needs.preflight.result != 'success' || needs.hop.result != 'success' - working-directory: busbar-release - run: $RUNNER_TEMP/busbar-release ci fallback - env: { GH_TOKEN: "${{ github.token }}", PR_NUMBER: "${{ github.event.pull_request.number }}", PREFLIGHT_RESULT: "${{ needs.preflight.result }}", PREFLIGHT_REASON: "${{ needs.preflight.outputs.reason }}", HOP_RESULT: "${{ needs.hop.result }}", HEALTH_BRANCHES: none } diff --git a/CHANGELOG.md b/CHANGELOG.md index 7f12b2eac8..75af599f25 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -950,6 +950,16 @@ each dialect translates, field by field, is listed in the generated ToolUse input and ToolResult content); 1.5.5 showed only text blocks. - A Bedrock response served from an Anthropic upstream carries `usage.cacheDetails`, the per-TTL split of the cache-write tokens (additive: billed counts are unchanged). +- A translated OpenAI Chat answer carries a model refusal in `message.refusal` / `delta.refusal` + and the serving `service_tier`, an image answer omits `created` when the upstream sent none, and + a translated request keeps a `developer` system prompt in the `developer` role. +- A translated OpenAI Responses request keeps a `developer` system prompt as a `developer` input + item and sends a JSON tool result as the `function_call_output` text, and a translated Responses + stream ends at `response.failed` and names the serving `service_tier`. +- A translated Gemini request or answer carries `functionCall.id` / `functionResponse.id`, a failed + tool result as `functionResponse.response.error`, a `fileData.mimeType`, the caller's + transcription prompt and temperature, and reads Vertex `createTime`, a `functionResponse.id` as + the call id, and the image content-policy stops as safety stops. - A buffered OpenAI chat response carries its citations as nested `url_citation` annotations, the shape the published Chat Completions schema defines. - On a same-dialect route the caller receives every upstream response header except hop-by-hop diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8aea311471..5e4db11e43 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -26,7 +26,7 @@ This page is the short version. Where the two differ, Part 6 wins. both sides moved (theirs + ours − base; anything else stays a conflict): `git config merge.ledger-count.driver "cargo xtask ledger-merge %O %A %B"`. - Builds, tests, clippy, the gates and the oracle run in CI (ModernCI for `predev` PRs; `.github/workflows/promote.yml` for the promotion). + Builds, tests, clippy, the gates and the oracle run in CI: ModernCI, for `predev` PRs and for every promotion (`.ci/*.yaml`). 3. Push the lane and open a PR into `predev`. ModernCI (required check `ci`) judges and lands it. Then stop; CI is the proof and a red comes back as a task. 4. The PR body says what changed, why, and the ruling or issue it implements. No AI attribution. diff --git a/Cargo.lock b/Cargo.lock index ca11475887..fe900ce5d4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -579,6 +579,7 @@ dependencies = [ "axum", "busbar-contract", "busbar-kernel", + "busbar-kernel-egress", "busbar-plugin-loader", "busbar-transport-tcp-plugin", "bytes", @@ -733,7 +734,6 @@ dependencies = [ "busbar-kernel-scope", "busbar-kernel-wal", "busbar-plugin-loader", - "busbar-store-memory", "bytes", "crc32fast", "criterion", diff --git a/Cargo.toml b/Cargo.toml index eb15df80ac..289dea8d45 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -108,10 +108,10 @@ busbar-auth-sigv4 = { git = "https://github.com/GetBusbar/busbar-auth-sigv4", re busbar-hook-ranking = { git = "https://github.com/GetBusbar/busbar-hook-ranking", rev = "7e1285e2e3293627723ab2754b9ddbfddeba9c46" } # THE MEMORY STORE (kind: store), pulled at a pinned rev: the logic crate the root links on its store # axis in every build (`[package.metadata.busbar.linked] store-memory`) and the plugin loader's `store` -# both-ways fixture. The same package at the same rev, under the role the kernel's test build gives it -# (`fixture-store`: a kernel source names no plugin). A rev bump moves both lines. +# both-ways fixture. The kernel names it under no name: its test build links the in-crate R-FIX3 +# double (`busbar_kernel::test_support::store_double`), and the root's `linked_store_double` tests +# prove the double states what this door states. busbar-store-memory = { git = "https://github.com/GetBusbar/busbar-store-memory", rev = "848851e63ac7a454eb3d5daf9e11d739c48d738b" } -fixture-store = { package = "busbar-store-memory", git = "https://github.com/GetBusbar/busbar-store-memory", rev = "848851e63ac7a454eb3d5daf9e11d739c48d738b" } # ── A2A, from the a2aproject's OWN crates ────────────────────────────────────────────────────── # THE OBVIOUS NAME IS THE WRONG ONE. `a2a-rs` on crates.io is `emillindfors/a2a-rs` (MIT, v0.5.0) — # a different author entirely. The a2aproject family is the `a2a-*-lf` / `a2a-pb` set, all diff --git a/README.md b/README.md index fb5f0810aa..aba9701729 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,6 @@

-promote Coverage OpenSSF Scorecard OpenSSF Best Practices diff --git a/RELEASE.md b/RELEASE.md index d13efc6719..8680b85a0c 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -1,33 +1,21 @@ # Releasing Busbar -The branch ladder is **predev → dev → qa → main**. Pull requests into `predev` are judged and landed by -ModernCI (required check `ci`, configured by [`.ci/default.yaml`](.ci/default.yaml)). The promotion -from `predev` to `dev` runs [`.github/workflows/promote.yml`](.github/workflows/promote.yml). The release engine lives in the private `GetBusbar/busbar-release` repository -(pinned outside this repo by the org variables `ENGINE_REF` and `ENGINE_SHA256`, set with -`busbar-release engine pin `; `workflow-rules` R16 holds promote.yml to a 40-hex check and a -sha256 verify of the engine at run time); busbar-release's `crates/busbar-release-train/ladder.json` -says what entering each rung needs. - -| Rung entered (the PR's base) | What the hop runs | Consent | -|---|---|---| -| **`predev`** (from a `lane-*` branch) | every registered gate (`cargo xtask gate --list`, each by name) | none | -| **`dev`** | the gates, the oracle against the 1.5.5 golden, conformance | none | -| **`qa`** | the same, plus build + test, then the release binaries and the container | the owner (the org ruleset keeps `qa` locked) | -| **`main`** | the same, then publish | the owner (the org ruleset keeps `main` locked) | - -A hop has two jobs, both in `promote.yml`: - -* **`preflight`**, on a free runner: `cargo fmt --all -- --check`, `cargo metadata --locked`, and the - generated C header is current (`cargo xtask gate abi-header --write` leaves no diff). -* **`hop`**, on a Latchkey runner sized by the rung: the pinned engine plans the hop and admits the - candidate sha, and the verdict is uploaded as an artifact. When - the rung needs no consent and the repository variable `PROMOTE_BOARD` is `true`, the engine - fast-forwards the base branch to the exact sha it tested. Both promotions are **fast-forward - pushes**: linear history is what makes "main's HEAD *is* the qa commit" true. - -> The entry builds a plan names (`release-binaries`, `container`, `publish`) are not yet ported into -> the engine: `promote.yml` fails a hop that plans one with that message rather than skipping it. -> Nothing is built or published until they land in busbar-release. +The branch ladder is **predev → dev → qa → main**, and ModernCI judges and moves every rung from +this repository's branch files (OWNER 2026-10-12, "ModernCI only"; the ruling is +[`docs/design/BUSBAR-1.6.0.md`](docs/design/BUSBAR-1.6.0.md) Part 6, "BUSBAR-RELEASE RETIRED FROM +BUSBAR"). A branch file is named for the branch being moved and says what the move must pass. + +| Move | Branch file | What it runs | Consent | +|---|---|---|---| +| a `lane-*` branch into **`predev`** | [`.ci/default.yaml`](.ci/default.yaml) | preflight, every registered gate, clippy, compile, test, doctest, deletion matrix, `cargo deny`; no worse than predev (required check `ci`) | none | +| **`predev`** into **`dev`** | [`.ci/predev.yaml`](.ci/predev.yaml) | the gates, the oracle against the 1.5.5 golden, conformance, `scripts/verify-1.6.0-done.sh`, the Q128 audit | the owner's approval | +| **`dev`** into **`qa`** | `.ci/dev.yaml` | the same, plus build and test, the fuzz smoke and the signing gate, the soak, the release binaries and the container | the owner (the org ruleset keeps `qa` locked) | +| **`qa`** into **`main`** | `.ci/qa.yaml` | the same; on landing, the release is signed and published from the build that was judged, never rebuilt | the owner (the org ruleset keeps `main` locked) | + +`.ci/dev.yaml` and `.ci/qa.yaml` are not written yet: until they land, nothing is built or published +from `qa` or `main`. The busbar-release hop (`promote.yml`, the engine pin) is deleted. busbar-release +remains the home of the oracle (`busbar-oracle`, pinned by `testing/shadow-oracle/oracle-rust.pin`), +the soak and the plugin fleet generator. **The tag is an OUTPUT of a green release, never an input.** Nobody tags by hand; no workflow is triggered by a `v*` tag (`cargo xtask gate workflow-rules`, rule R1). Docker Hub tag immutability @@ -49,19 +37,17 @@ python3 .github/scripts/roll_changelog.py 1.6.0 CHANGELOG.md ``` That bumps `crates/busbar/Cargo.toml` + `Cargo.lock`, regenerates the committed OpenAPI schema, and -rolls `[Unreleased]` to `[1.6.0]` with today's date. It does **not** tag. Open the result as a pull -request against `dev` and let the hop run. +rolls `[Unreleased]` to `[1.6.0]` with today's date. It does **not** tag. Land it on `predev` like +any other change; it reaches `dev` with the next promotion. ## Moving a version up the ladder -Open a pull request whose base is the next rung (`dev` into `qa`, `qa` into `main`; a lane branch into `predev`). `promote.yml` -runs the hop on the PR; the run's check names are `preflight` and `hop`. A red hop is fixed -forward on the lower branch and the pull request re-run: every push re-runs the hop on the new sha. +ModernCI opens and judges each promotion (`modernci call moves` shows how each stands). A red move +is fixed forward on the lower branch: every push re-judges it on the new head. ## Branch protection this assumes -`qa` and `main` require the two contexts `preflight` and `hop` (plus the org ruleset's owner -consent). +`predev` requires ModernCI's check `ci`; `qa` and `main` take the owner's consent (the org ruleset). Branch protection lives in repository rulesets, not in a script (see `.github/required-status-checks.md`). Keep `enforce_admins: true`, `required_linear_history: true`, `allow_force_pushes: false` and diff --git a/bin/oracle b/bin/oracle index 8512b08b6c..4f6765c6ae 100755 --- a/bin/oracle +++ b/bin/oracle @@ -267,11 +267,14 @@ shim_dir="$root/tool" mkdir -p "$root" # ── obtain the Rust engine ────────────────────────────────────────────────────────────────────── -# Precedence (soundest first, per the Phase C ruling "prefer `cargo build --release` from the pinned -# busbar-release checkout"): -# 1. BUSBAR_ORACLE_RUST_BIN= a prebuilt binary (CI builds it once, passes it here). -# 2. BUSBAR_RELEASE_CHECKOUT=

a busbar-release checkout to `cargo build --release` from. -# 3. else git clone busbar-release at the pinned ref, then build. +# Precedence: +# 1. BUSBAR_ORACLE_RUST_BIN= a prebuilt binary chosen by the operator (verify-1.6.0-done.sh +# refuses a DONE run with it set). +# 2. on Linux: THE FETCHED JUDGE busbar-release's published `busbar-oracle-linux--`, +# run only when its sha256 is the BASE pin's line for this +# architecture (fetch_judge, below). No line: no judge, refused. +# 3. elsewhere (a laptop): BUSBAR_RELEASE_CHECKOUT=, a busbar-release checkout to +# `cargo build --release` from, else git clone busbar-release at the pinned ref, then build. # ── the engine clone: a token is USED, never STORED (Q128 WARN, ARCHITECT 2026-10-05) ────────────── # The token (the first of BUSBAR_RELEASE_TOKEN / GH_TOKEN / GITHUB_TOKEN that is set) reaches git as a # one-shot `http..extraheader` through git's environment config (GIT_CONFIG_COUNT/KEY/VALUE): @@ -352,11 +355,176 @@ if [ "${1:-}" = "token-selftest" ]; then [ "$fail" = 0 ] && { echo "oracle token-selftest: the engine clone stores no credential"; exit 0; } echo "oracle token-selftest: FAILED"; exit 1 fi +# ── the pinned judge, FETCHED (OWNER 2026-10-13: no hand-installed tools; ModernCI installs no judge) ── +# On Linux the judge is the binary busbar-release's dev-bin.yml built and published from the pinned +# commit: `busbar-oracle-linux--` on the `dev-bin` release, run only when its +# sha256 is the pin's `sha256_linux_` line. The pin read is the BASE's: $MODERNCI_TO_SHA's +# testing/shadow-oracle/oracle-rust.pin when ModernCI names a base that holds one (a candidate never +# picks its own judge; a pin bump judges from the commit after it lands), else this tree's (the first +# predev -> dev move: dev holds no pin; .ci/predev.yaml says so). A pin with no line for this host's +# architecture has NO JUDGE (red, never another architecture's, never a source build). A `.sig` beside +# the asset is verified against busbar-release's release key (Ed25519 over "\n\n", +# busbar-release-cli update.rs RELEASE_KEY_SPKI) before the bytes are ever made executable; a +# `.sha256` beside it must agree with the pin. The verified binary is cached by its sha256 and +# re-hashed on every use, so a cache cannot substitute a judge. The token (BUSBAR_RELEASE_TOKEN, else +# GH_TOKEN, else GITHUB_TOKEN) reaches curl on stdin as a config line: never on an argv, never on disk. +ORACLE_RELEASE_API="https://api.github.com/repos/GetBusbar/busbar-release" +ORACLE_RELEASE_KEY_PEM='-----BEGIN PUBLIC KEY----- +MCowBQYDK2VwAyEAT8gl0GhaEpxYXdRqfeNZ6KU8KgmI+8qbtP8UZPNPJiU= +-----END PUBLIC KEY-----' +_pin_in() { awk -F= -v k="$1" '$1==k{sub(/^[^=]*=/,"");print;exit}'; } +sha256_of() { # sha256_of — the hex digest, or nothing + if command -v sha256sum >/dev/null 2>&1; then sha256sum -- "$1" | cut -c1-64 + else shasum -a 256 -- "$1" | cut -c1-64; fi +} +judge_arch() { local a; a="$(uname -m)"; [ "$a" = arm64 ] && a=aarch64; printf '%s' "$a"; } +judge_pin_text() { # judge_pin_text — the BASE pin's text (see above) + local repo="$1" rel="testing/shadow-oracle/oracle-rust.pin" + if [ -n "${MODERNCI_TO_SHA:-}" ] && git -C "$repo" cat-file -e "$MODERNCI_TO_SHA:$rel" 2>/dev/null; then + git -C "$repo" show "$MODERNCI_TO_SHA:$rel" + else + cat "$repo/$rel" + fi +} +release_api() { # release_api [ []] — an authenticated GET; the token never on an argv + local tok="${BUSBAR_RELEASE_TOKEN:-${GH_TOKEN:-${GITHUB_TOKEN:-}}}" + [ -n "$tok" ] || { echo "oracle: no token to read busbar-release's dev-bin release (BUSBAR_RELEASE_TOKEN, GH_TOKEN or GITHUB_TOKEN)" >&2; return 1; } + local accept="${2:-application/vnd.github+json}" + if [ -n "${3:-}" ]; then + printf 'header = "Authorization: Bearer %s"\n' "$tok" | curl -fsSL --retry 3 -K - -H "Accept: $accept" -o "$3" "$1" + else + printf 'header = "Authorization: Bearer %s"\n' "$tok" | curl -fsSL --retry 3 -K - -H "Accept: $accept" "$1" + fi +} +judge_assets() { # judge_assets — every dev-bin asset as `\t` + local out="$1" rid page n + rid="$(release_api "$ORACLE_RELEASE_API/releases/tags/dev-bin" | jq -r '.id')" || return 1 + [[ "$rid" =~ ^[0-9]+$ ]] || { echo "oracle: busbar-release has no dev-bin release" >&2; return 1; } + : >"$out" + for page in $(seq 1 20); do + n="$(release_api "$ORACLE_RELEASE_API/releases/$rid/assets?per_page=100&page=$page" \ + | jq -r '.[] | "\(.id)\t\(.name)"' | tee -a "$out" | wc -l)" || return 1 + [ "$n" -lt 100 ] && break + done + return 0 +} +fetch_asset() { # fetch_asset — : a directory (the selftest) or an asset list + local src="$1" name="$2" out="$3" id + if [ -d "$src" ]; then [ -f "$src/$name" ] && cp "$src/$name" "$out"; return; fi + id="$(awk -F'\t' -v n="$name" '$2==n{print $1; exit}' "$src")" + [ -n "$id" ] || return 1 + release_api "$ORACLE_RELEASE_API/releases/assets/$id" application/octet-stream "$out" +} +verify_judge_sig() { # verify_judge_sig + local pem="$1" name="$2" hex="$3" sig="$4" d rc=0 + command -v openssl >/dev/null 2>&1 || { echo "oracle: refusing the judge: $name has a .sig and there is no openssl to verify it" >&2; return 1; } + d="$(mktemp -d)" + printf '%s\n' "$pem" >"$d/key.pem" + printf '%s\n%s\n' "$name" "$hex" >"$d/msg" + if ! tr -d '\n\r ' <"$sig" | base64 -d >"$d/sig" 2>/dev/null; then rc=1 + elif ! openssl pkeyutl -verify -pubin -inkey "$d/key.pem" -rawin -in "$d/msg" -sigfile "$d/sig" >/dev/null 2>&1; then rc=1; fi + rm -rf "$d" + [ "$rc" = 0 ] || echo "oracle: refusing the judge: $name's .sig is not busbar-release's release key's signature of it" >&2 + return "$rc" +} +fetch_judge() { # fetch_judge [] — prints the verified judge + local text="$1" cache="$2" src="$3" pem="${4:-$ORACLE_RELEASE_KEY_PEM}" arch ref want name bin tmp got side + arch="$(judge_arch)" + ref="$(printf '%s\n' "$text" | _pin_in release_ref)" + want="$(printf '%s\n' "$text" | _pin_in "sha256_linux_$arch")" + [[ "$ref" =~ ^[0-9a-f]{40}$ ]] || { echo "oracle: no judge: the pin names no full 40-hex release_ref" >&2; return 1; } + [[ "$want" =~ ^[0-9a-f]{64}$ ]] || { echo "oracle: no judge: the pin has no sha256_linux_$arch line, and a host whose architecture the pin does not name has no judge" >&2; return 1; } + name="busbar-oracle-linux-$arch-$ref" + bin="$cache/$want/busbar-oracle" + if [ -x "$bin" ] && [ "$(sha256_of "$bin")" = "$want" ]; then printf '%s\n' "$bin"; return 0; fi + tmp="$(mktemp -d)" + if [ "$src" = api ]; then judge_assets "$tmp/assets.tsv" || { rm -rf "$tmp"; return 1; }; src="$tmp/assets.tsv"; fi + fetch_asset "$src" "$name" "$tmp/$name" || { echo "oracle: no judge: $name is not on busbar-release's dev-bin release" >&2; rm -rf "$tmp"; return 1; } + got="$(sha256_of "$tmp/$name")" + [ "$got" = "$want" ] || { echo "oracle: refusing the judge: $name has sha256 $got, the pin says $want" >&2; rm -rf "$tmp"; return 1; } + if fetch_asset "$src" "$name.sha256" "$tmp/$name.sha256" 2>/dev/null; then + side="$(cut -c1-64 <"$tmp/$name.sha256")" + [ "$side" = "$want" ] || { echo "oracle: refusing the judge: its published .sha256 ($side) is not the pin's $want" >&2; rm -rf "$tmp"; return 1; } + fi + if fetch_asset "$src" "$name.sig" "$tmp/$name.sig" 2>/dev/null; then + verify_judge_sig "$pem" "$name" "$got" "$tmp/$name.sig" || { rm -rf "$tmp"; return 1; } + fi + mkdir -p "$cache/$want" + mv "$tmp/$name" "$bin.part" && chmod +x "$bin.part" && mv -f "$bin.part" "$bin" + rm -rf "$tmp" + printf '%s\n' "$bin" +} +if [ "${1:-}" = "fetch-selftest" ]; then + # RED-before-green for the fetched judge, offline: a fixture "release" directory stands in for the + # dev-bin API (fetch_asset's directory source), and a throwaway Ed25519 key for busbar-release's. + st="$(mktemp -d)"; trap 'rm -rf "$st"' EXIT + fail=0 arch="$(judge_arch)" ref="0123456789abcdef0123456789abcdef01234567" + name="busbar-oracle-linux-$arch-$ref" + mkdir -p "$st/rel" "$st/cache" + printf '#!/bin/sh\necho planted-judge\n' >"$st/rel/$name" + good="$(sha256_of "$st/rel/$name")" + pin_ok="release_ref=$ref +sha256_linux_$arch=$good" + if p="$(fetch_judge "$pin_ok" "$st/cache" "$st/rel" 2>/dev/null)" && [ "$("$p")" = planted-judge ]; then + echo " [ok] the pinned digest's judge is fetched, cached and runs" + else echo " [FAILED] the pinned digest's judge was not fetched"; fail=1; fi + rm -f "$st/rel/$name" + if p="$(fetch_judge "$pin_ok" "$st/cache" "$st/rel" 2>/dev/null)" && [ "$("$p")" = planted-judge ]; then + echo " [ok] a cached judge is reused by its digest, with no source" + else echo " [FAILED] the cached judge was not reused"; fail=1; fi + printf '#!/bin/sh\necho planted-judge\n' >"$st/rel/$name" + pin_wrong="release_ref=$ref +sha256_linux_$arch=$(printf 'f%.0s' $(seq 1 64))" + if fetch_judge "$pin_wrong" "$st/cache2" "$st/rel" >/dev/null 2>"$st/err"; then + echo " [FAILED] a judge whose sha256 is not the pin's ran"; fail=1 + elif grep -q "refusing the judge" "$st/err"; then echo " [ok] a wrong sha256 refuses the judge" + else echo " [FAILED] the wrong-sha refusal did not say so: $(cat "$st/err")"; fail=1; fi + if fetch_judge "release_ref=$ref" "$st/cache3" "$st/rel" >/dev/null 2>"$st/err"; then + echo " [FAILED] a pin with no line for this architecture produced a judge"; fail=1 + elif grep -q "no judge" "$st/err"; then echo " [ok] a pin with no sha256_linux_$arch line has no judge" + else echo " [FAILED] the missing-line refusal did not say \"no judge\": $(cat "$st/err")"; fail=1; fi + printf 'e%.0s' $(seq 1 64) >"$st/rel/$name.sha256" + if fetch_judge "$pin_ok" "$st/cache4" "$st/rel" >/dev/null 2>/dev/null; then + echo " [FAILED] a published .sha256 disagreeing with the pin was accepted"; fail=1 + else echo " [ok] a published .sha256 disagreeing with the pin refuses"; fi + printf '%s %s\n' "$good" "$name" >"$st/rel/$name.sha256" + if command -v openssl >/dev/null 2>&1 && openssl genpkey -algorithm ed25519 -out "$st/k.pem" 2>/dev/null \ + && openssl pkey -in "$st/k.pem" -pubout -out "$st/pub.pem" 2>/dev/null; then + printf '%s\n%s\n' "$name" "$good" >"$st/msg" + openssl pkeyutl -sign -inkey "$st/k.pem" -rawin -in "$st/msg" -out "$st/sig.bin" 2>/dev/null + base64 <"$st/sig.bin" | tr -d '\n' >"$st/rel/$name.sig" + if fetch_judge "$pin_ok" "$st/cache5" "$st/rel" "$(cat "$st/pub.pem")" >/dev/null 2>&1; then + echo " [ok] a .sig made by the release key is accepted" + else echo " [FAILED] a good .sig was refused"; fail=1; fi + if fetch_judge "$pin_ok" "$st/cache6" "$st/rel" >/dev/null 2>"$st/err"; then + echo " [FAILED] a .sig made by another key was accepted under busbar-release's key"; fail=1 + elif grep -q "release key" "$st/err"; then echo " [ok] a .sig made by another key refuses" + else echo " [FAILED] the bad-.sig refusal did not say so: $(cat "$st/err")"; fail=1; fi + else echo " [FAILED] no openssl with Ed25519 here: the .sig check cannot be proven"; fail=1; fi + # THE BASE PIN wins over the tree's when ModernCI names a base that holds one. + git init -q "$st/repo" && mkdir -p "$st/repo/testing/shadow-oracle" \ + && printf 'release_ref=base\n' >"$st/repo/testing/shadow-oracle/oracle-rust.pin" \ + && git -C "$st/repo" add -A && git -C "$st/repo" -c user.name=t -c user.email=t@t.invalid \ + -c commit.gpgsign=false -c core.hooksPath=/dev/null commit -q -m pin \ + && printf 'release_ref=head\n' >"$st/repo/testing/shadow-oracle/oracle-rust.pin" \ + || { echo " [FAILED] could not build the base-pin fixture"; fail=1; } + b="$(git -C "$st/repo" rev-parse HEAD 2>/dev/null)" + if [ "$(MODERNCI_TO_SHA="$b" judge_pin_text "$st/repo")" = "release_ref=base" ] \ + && [ "$(MODERNCI_TO_SHA= judge_pin_text "$st/repo")" = "release_ref=head" ]; then + echo " [ok] the base's pin is read when ModernCI names a base, the tree's otherwise" + else echo " [FAILED] the judge's pin was not read from the base"; fail=1; fi + [ "$fail" = 0 ] && { echo "oracle fetch-selftest: the judge is the pinned digest's, or there is none"; exit 0; } + echo "oracle fetch-selftest: FAILED"; exit 1 +fi resolve_bin() { if [ -n "${BUSBAR_ORACLE_RUST_BIN:-}" ]; then [ -x "$BUSBAR_ORACLE_RUST_BIN" ] || { echo "oracle: BUSBAR_ORACLE_RUST_BIN is not executable: $BUSBAR_ORACLE_RUST_BIN" >&2; exit 1; } printf '%s\n' "$BUSBAR_ORACLE_RUST_BIN"; return 0 fi + if [ "$(uname -s)" = Linux ]; then + fetch_judge "$(judge_pin_text "$here")" "${XDG_CACHE_HOME:-$HOME/.cache}/busbar-oracle-judge" api || exit 1 + return 0 + fi local built="$root/checkout/target/release/$BINNAME" if [ -x "$built" ]; then printf '%s\n' "$built"; return 0; fi local checkout diff --git a/conformance/manifest.json b/conformance/manifest.json index a154e21d4f..a7f59508cf 100644 --- a/conformance/manifest.json +++ b/conformance/manifest.json @@ -37,7 +37,7 @@ }, { "claim": null, - "plan": "plane battery + served POST /v1/systemone on a booted busbar (systemone only; GET /v1/models keeps its 1.5.5 bytes)", + "plan": "plane battery + served POST /v1/systemone (one model; two models with upstream_model) and POST /api/evaluate (nanojev) on booted busbars; GET /v1/models keeps its 1.5.5 bytes", "public": true, "reason": "not served: POST /v1/systemone answers 404, the absence code on this boot; the decisions plane is registered and configured but no route serves its operation (the kernel plane driver serves it)", "standard": "jev decision plane", diff --git a/conformance/registry.toml b/conformance/registry.toml index 5b5bdc7d9e..e15f07d5de 100644 --- a/conformance/registry.toml +++ b/conformance/registry.toml @@ -149,7 +149,7 @@ not_run_reason = "planned 1.6.0 — h2spec leg not yet green (MATRIX-RULING B-ti [[suite]] id = "jev" standard = "jev decision plane" -plan = "plane battery + served POST /v1/systemone on a booted busbar (systemone only; GET /v1/models keeps its 1.5.5 bytes)" +plan = "plane battery + served POST /v1/systemone (one model; two models with upstream_model) and POST /api/evaluate (nanojev) on booted busbars; GET /v1/models keeps its 1.5.5 bytes" tier = "spec-conformant" label = "jev" verdict = "conformance-verdict-jev" diff --git a/crates/busbar-a2a/src/a2a/config.rs b/crates/busbar-a2a/src/a2a/config.rs index 0076bcdaa5..5fb69036f7 100644 --- a/crates/busbar-a2a/src/a2a/config.rs +++ b/crates/busbar-a2a/src/a2a/config.rs @@ -299,7 +299,7 @@ pub struct AgentDefCfg { /// /// WHAT IT NEVER PERMITS, and this is the half that makes the knob safe to have: a /// CLOUD-METADATA endpoint. `169.254.169.254` and its family are refused whether this is set or - /// not — see [`busbar_kernel::net_guard::ip_is_cloud_metadata`], whose own doc gives the reason: an + /// not — see [`busbar_contract::net::ip_is_cloud_metadata`], whose own doc gives the reason: an /// `allow_private` that reached IMDS would be a configuration flag that hands out cloud /// credentials. /// diff --git a/crates/busbar-a2a/src/a2a/fetch.rs b/crates/busbar-a2a/src/a2a/fetch.rs index f48ceaa689..b1984bed9f 100644 --- a/crates/busbar-a2a/src/a2a/fetch.rs +++ b/crates/busbar-a2a/src/a2a/fetch.rs @@ -2,7 +2,7 @@ // Copyright (C) 2026 Busbar Inc and contributors //! THE AGENT CARD FETCH: the card-shaped part of a guarded fetch, over the guard in -//! [`busbar_kernel::net_guard`]. +//! [`busbar_kernel::proxy::egress_unit::trust::net`]. //! //! Everything else on this plane decides. This is the one module that REACHES OUT, at a URL an //! operator wrote and following redirects a stranger controls, and it therefore carries the whole @@ -11,7 +11,7 @@ //! ## THE GUARD IS NOT HERE, and that is the point //! //! Resolve-then-pin, the address judgement, the metadata arm that sits ahead of `allow_private`, the -//! hop bound and the body cap all live in [`busbar_kernel::net_guard`]. They were written twice — once here +//! hop bound and the body cap all live in [`busbar_kernel::proxy::egress_unit::trust::net`]. They were written twice — once here //! and once for the MCP dispatch path — and the doc comments in each copy had already started //! citing the other's function names as the reason an ordering was correct. A security control that //! has to cite its twin to explain itself has two implementations and one of them will be the stale @@ -52,7 +52,9 @@ use std::net::IpAddr; -use busbar_kernel::net_guard::{self, AddressRefusal, GuardPolicy, PinnedTarget}; +use busbar_kernel::proxy::egress_unit::trust::net::{ + self, AddressRefusal, GuardPolicy, PinnedTarget, +}; use super::card::{WELL_KNOWN_CARD_PATH, WELL_KNOWN_CARD_PATH_LEGACY}; @@ -61,7 +63,7 @@ use super::card::{WELL_KNOWN_CARD_PATH, WELL_KNOWN_CARD_PATH_LEGACY}; /// Re-exported rather than redeclared: a second trait with the same shape would let a transport be /// written against one and a guard against the other, which is how two implementations of one /// control start. -pub(crate) use busbar_kernel::net_guard::Resolver; +pub(crate) use busbar_kernel::proxy::egress_unit::trust::net::Resolver; /// The operator's fetch policy. Config, therefore intent. #[derive(Clone, Debug, PartialEq, Eq)] @@ -88,7 +90,7 @@ pub(crate) struct FetchPolicy { /// concept and two spellings of it would be two things to learn and two things to get wrong. /// It relaxes the loopback/private ARMS of the guard and NOTHING else: a cloud-metadata name /// and a cloud-metadata address are refused with this set, because - /// [`busbar_kernel::net_guard::judge_address`] tests the metadata arm BEFORE it reads this flag, and + /// [`busbar_kernel::proxy::egress_unit::trust::net::judge_address`] tests the metadata arm BEFORE it reads this flag, and /// the alternate-IPv4-encoding arm is likewise unconditional. An `allow_private` that reached /// IMDS would be a config flag that hands out cloud credentials. pub(crate) allow_private: bool, @@ -320,7 +322,7 @@ impl Pin { } } -/// GUARD ONE HOP AND PIN IT: the card fetch's door onto [`busbar_kernel::net_guard::resolve_and_pin`]. +/// GUARD ONE HOP AND PIN IT: the card fetch's door onto [`busbar_kernel::proxy::egress_unit::trust::net::resolve_and_pin`]. /// /// Returns the parsed URL BESIDE the pin, because the two are needed together and for different /// things: the socket goes to [`PinnedTarget::addr`], and the request carries the URL — its host in @@ -332,10 +334,10 @@ impl Pin { /// /// The one difference between the two callers of the guard that could NOT be parameterised away. /// The MCP dispatch path wants a strict recogniser on an attacker-influenced string, and gets -/// [`busbar_kernel::net_guard::split_url`]. This path must FOLLOW redirects, and following one means +/// [`busbar_kernel::proxy::egress_unit::trust::net::split_url`]. This path must FOLLOW redirects, and following one means /// joining a relative `Location` against the hop that sent it exactly as a client would — which /// needs a real URL type and its resolution rules, not a splitter. Both then bring the host they -/// parsed through the SAME [`busbar_kernel::net_guard::judge_host_name`] and the same resolve-then-pin, so +/// parsed through the SAME [`busbar_kernel::proxy::egress_unit::trust::net::judge_host_name`] and the same resolve-then-pin, so /// what differs is the recognition and never the judgement. pub(crate) fn guard_hop( url: &str, @@ -364,7 +366,7 @@ pub(crate) fn guard_hop( // `http://127.0.0.1` has made ONE decision; making them write two flags would teach that the // second one is harmless. `allow_plaintext` stays its own field because a plaintext fetch of a // PUBLIC host is a different, worse thing than a plaintext fetch of loopback. - if net_guard::judge_scheme(parsed.as_str(), https, guard).is_err() { + if net::judge_scheme(parsed.as_str(), https, guard).is_err() { return Err(FetchRefusal::NotHttps { url: parsed.to_string(), scheme, @@ -382,12 +384,12 @@ pub(crate) fn guard_hop( let host = host.strip_suffix('.').unwrap_or(host); let port = parsed .port_or_known_default() - .unwrap_or_else(|| net_guard::default_port(https)); + .unwrap_or_else(|| net::default_port(https)); // ── THE GUARD. Structural name refusals, then EXACTLY ONE resolution, then EVERY answered // address, then the pin. All of it core's, including the ordering that keeps the // cloud-metadata arm ahead of `allow_private`. - let target = net_guard::resolve_and_pin(host, port, https, resolver, guard)?; + let target = net::resolve_and_pin(host, port, https, resolver, guard)?; Ok((parsed, target)) } @@ -466,7 +468,7 @@ pub(crate) fn fetch_card( if (300..400).contains(&resp.status) { // THE HOP BOUND IS CORE'S. A guard applied correctly to an unbounded chain is a way to // spend the process, and the bound is the same one every guarded fetch gets. - net_guard::refuse_hop_overflow(hops, url.as_str(), policy.guard())?; + net::refuse_hop_overflow(hops, url.as_str(), policy.guard())?; let location = resp .location .as_deref() @@ -499,7 +501,7 @@ pub(crate) fn fetch_card( } // THE BODY CAP IS CORE'S, and it is applied BEFORE the bytes are parsed. - net_guard::refuse_oversized_body(url.as_str(), resp.body.len(), policy.guard())?; + net::refuse_oversized_body(url.as_str(), resp.body.len(), policy.guard())?; let document: serde_json::Value = serde_json::from_slice(&resp.body).map_err(|e| FetchRefusal::NotACard { url: url.to_string(), diff --git a/crates/busbar-a2a/src/a2a/mod.rs b/crates/busbar-a2a/src/a2a/mod.rs index 757c445312..8f31aac98f 100644 --- a/crates/busbar-a2a/src/a2a/mod.rs +++ b/crates/busbar-a2a/src/a2a/mod.rs @@ -33,7 +33,7 @@ // // AND THE ROUTER NOW RELAYS. [`relay`] is the hop `ingress::invoke` makes to the registered backend // agent: it guards and pins the target through the SAME `fetch::guard_hop` — and therefore the -// same `busbar_kernel::net_guard` resolve-then-pin — the card fetch +// same the egress unit's guard (`busbar_kernel::proxy::egress_unit::trust::net`) resolve-then-pin — the card fetch // uses, RE-ASKS the trust question against the live registry immediately before the socket so a // mid-flight demotion is not something an in-flight request escapes, presents BUSBAR'S OWN leased // credential or none, and turns every way the hop can fail into a busbar-attributed error rather diff --git a/crates/busbar-a2a/src/a2a/pushnotify.rs b/crates/busbar-a2a/src/a2a/pushnotify.rs index d85826ad7f..8be429f3bf 100644 --- a/crates/busbar-a2a/src/a2a/pushnotify.rs +++ b/crates/busbar-a2a/src/a2a/pushnotify.rs @@ -31,7 +31,7 @@ //! //! ## The address predicates are NOT re-implemented here //! -//! The CGNAT, unique-local, link-local and alternate-encoding atoms live in [`busbar_kernel::net_guard`] and +//! The CGNAT, unique-local, link-local and alternate-encoding atoms live in [`busbar_kernel::proxy::egress_unit::trust::net`] and //! are used from there. Duplicated security logic is the one kind of duplication that cannot be made //! safe by documenting it: somebody hardens one copy against a new obfuscation and never learns the //! other exists. @@ -131,10 +131,10 @@ pub(crate) struct PinnedCallback { /// /// The tear-out went the other way too — the three ranges this copy had and the shared predicate /// did not (`0.0.0.0/8`, `192.0.0.0/24`, `198.18.0.0/15`) moved INTO -/// [`busbar_kernel::net_guard::ipv4_is_internal`] first, so no guard lost coverage in the unification. That +/// [`busbar_contract::net::ipv4_is_internal`] first, so no guard lost coverage in the unification. That /// ordering is the whole discipline: widen the shared predicate to the union, then delete the copy. pub(crate) fn is_internal_addr(ip: &IpAddr) -> bool { - busbar_kernel::net_guard::ip_is_internal(ip) + busbar_contract::net::ip_is_internal(ip) } /// Split a URL into `(scheme, host)`, the host read by the one shared URL reader. @@ -203,7 +203,7 @@ fn structural_check(url: &str) -> Result { if scheme != "https" { return Err(PushNotifyError::Scheme(scheme)); } - if busbar_kernel::net_guard::is_alternate_ipv4_encoding(&host) { + if busbar_contract::net::is_alternate_ipv4_encoding(&host) { return Err(PushNotifyError::ObfuscatedHost(host)); } // A canonical IP LITERAL is checked directly and is not subject to the resolver's answer at all diff --git a/crates/busbar-a2a/src/a2a/relay.rs b/crates/busbar-a2a/src/a2a/relay.rs index a5c04d7c8f..1c35de6af3 100644 --- a/crates/busbar-a2a/src/a2a/relay.rs +++ b/crates/busbar-a2a/src/a2a/relay.rs @@ -34,7 +34,7 @@ //! ## 2. THE NAME IS RESOLVED ONCE, BY THE GUARD, AND THE JUDGED ADDRESS IS WHAT CONNECTS //! //! This module does not open its own client. It reuses [`super::fetch::guard_hop`], and through it -//! [`busbar_kernel::net_guard::resolve_and_pin`] — the same +//! [`busbar_kernel::proxy::egress_unit::trust::net::resolve_and_pin`] — the same //! guard the card fetch goes through — and hands the surviving address to the transport, which pins //! it. A relay that handed the URL to `reqwest` and let the client resolve the host would reinstate //! the second lookup, which is the whole of DNS rebinding, and would pass every test that does not @@ -90,9 +90,8 @@ use super::creds::{Lease, LeaseError}; use super::fetch::{FetchPolicy, FetchRefusal, HttpResponse, Resolver}; use super::task::TaskState; -use busbar_kernel::{ - breaker::normalize_raw_error, net_guard::PinnedTarget, plane_host::breaker as host_breaker, -}; +use busbar_kernel::proxy::egress_unit::trust::net::PinnedTarget; +use busbar_kernel::{breaker::normalize_raw_error, plane_host::breaker as host_breaker}; /// The HTTP round trip the relay makes, as a seam. /// @@ -1610,7 +1609,7 @@ fn prepare<'a>( admit_id: &mut busbar_contract::abi::hot::AdmissionId, ) -> Result<(url::Url, PinnedTarget, OutboundRelayRequest), RelayRefusal> { // ── THE GUARD. One resolution, every answered address judged, one pinned address out. It is - // `busbar_kernel::net_guard`'s, reached through the card fetch's hop door, so a relayed submission + // the egress unit's guard (`busbar_kernel::proxy::egress_unit::trust::net`)'s, reached through the card fetch's hop door, so a relayed submission // and a card fetch cannot be guarded to two different standards. // `call.policy`, NOT `seam.policy()`. The seam answers with the plane's fail-closed default and // knows nothing about any registration; the call carries the one the operator's `allow_private:` diff --git a/crates/busbar-a2a/src/a2a/tests/fetch_tests.rs b/crates/busbar-a2a/src/a2a/tests/fetch_tests.rs index b5be265c55..7deaf7015c 100644 --- a/crates/busbar-a2a/src/a2a/tests/fetch_tests.rs +++ b/crates/busbar-a2a/src/a2a/tests/fetch_tests.rs @@ -830,7 +830,7 @@ fn allow_private_never_opens_a_cloud_metadata_target() { } /// `allow_private` ADMITS PLAINTEXT, because that is what the word means on the `tools:` plane -/// (both planes ask `busbar_kernel::net_guard::judge_scheme`, which refuses `http://` with exactly +/// (both planes ask `busbar_kernel::proxy::egress_unit::trust::net::judge_scheme`, which refuses `http://` with exactly /// `!https && !plaintext_admissible()`), and a /// loopback agent that could only be reached over TLS is a knob that does not do its job. #[test] diff --git a/crates/busbar-a2a/src/a2a/tests/pushnotify_tests.rs b/crates/busbar-a2a/src/a2a/tests/pushnotify_tests.rs index 281da258ab..0054a54fed 100644 --- a/crates/busbar-a2a/src/a2a/tests/pushnotify_tests.rs +++ b/crates/busbar-a2a/src/a2a/tests/pushnotify_tests.rs @@ -49,7 +49,7 @@ fn every_internal_range_is_refused() { ("IETF protocol 192.0.0/24", v4(192, 0, 0, 8)), ("benchmarking 198.18/15", v4(198, 19, 0, 1)), // AZURE WIRESERVER AND THE DOCUMENTATION BLOCK were missing from this plane's private copy - // of the predicate while the shared one in `net_guard` had them. `168.63.129.16` is the + // of the predicate while the shared one in `busbar_contract::net` had them. `168.63.129.16` is the // whole point: it is a PUBLIC address, so every range check in the copy missed it, and a // caller could register it as a push callback and have busbar fetch Azure's platform // metadata on its behalf. The copy's own doc claimed to cover "a cloud metadata address". diff --git a/crates/busbar-a2a/src/a2a/words.rs b/crates/busbar-a2a/src/a2a/words.rs index 54641ddb4c..14dd6836d5 100644 --- a/crates/busbar-a2a/src/a2a/words.rs +++ b/crates/busbar-a2a/src/a2a/words.rs @@ -10,7 +10,7 @@ //! shape is a body the official TCK rejects by schema. //! //! That split — **a caller keeps its refusal VOCABULARY, not its DECISION** — is -//! `busbar_kernel::net_guard`'s rule, stated for a second concern. It is why `mcp/envelope.rs` and this +//! the egress unit's guard (`busbar_kernel::proxy::egress_unit::trust::net`)'s rule, stated for a second concern. It is why `mcp/envelope.rs` and this //! file can say completely different things about the same refusal without either of them deciding //! when it happens. diff --git a/crates/busbar-contract/include/busbar_plugin.h b/crates/busbar-contract/include/busbar_plugin.h index 0d9082e9cf..8d07166e82 100644 --- a/crates/busbar-contract/include/busbar_plugin.h +++ b/crates/busbar-contract/include/busbar_plugin.h @@ -374,6 +374,10 @@ extern "C" { #define BB_PLANE_UNITS_ESTIMATED UINT32_C(0) /* [`UnitCount::source`]: the plane's own estimate; never billed. */ #define BB_PLANE_UNITS_REPORTED UINT32_C(1) /* [`UnitCount::source`]: the far end reported it; it bills. */ #define BB_PLANE_UNITS_FLOOR UINT32_C(2) /* [`UnitCount::source`]: the plane's floor for a delivered reply whose far-end usage could not be */ +#define BB_PLANE_TAP_FAULT_NONE UINT8_C(0) /* [`OnPieceOut::tap_fault`]: no usage-tap fault. */ +#define BB_PLANE_TAP_FAULT_DECODE UINT8_C(1) /* [`OnPieceOut::tap_fault`]: the cell's own reader refused the body (`reason="decode"`). */ +#define BB_PLANE_TAP_FAULT_BAD_JSON UINT8_C(2) /* [`OnPieceOut::tap_fault`]: the body is not JSON (`reason="bad_json"`). */ +#define BB_PLANE_TAP_FAULT_UNKNOWN_PROTOCOL UINT8_C(3) /* [`OnPieceOut::tap_fault`]: the caller's dialect is not one the tap reads */ #define BB_PLANE_PRINCIPAL_NONE UINT32_C(0) /* [`ArriveOut::principal_need`]: no principal. */ #define BB_PLANE_PRINCIPAL_REQUIRED UINT32_C(1) /* [`ArriveOut::principal_need`]: the kernel must verify a principal before the first piece. */ #define BB_PLANE_PRINCIPAL_OPTIONAL UINT32_C(2) /* [`ArriveOut::principal_need`]: verify one if the caller presents it. */ @@ -2793,7 +2797,8 @@ struct bb_plane_OnPieceOut { bb_mech_Span target; uint32_t need; uint8_t fault; - uint8_t _fault_reserved[3]; + uint8_t tap_fault; + uint8_t _fault_reserved[2]; bb_mech_Span lane; uint32_t final_status; uint32_t _final_reserved; @@ -5292,7 +5297,8 @@ BB_ASSERT(offsetof(bb_plane_OnPieceOut, verb) == 160, "bb_plane_OnPieceOut.verb: BB_ASSERT(offsetof(bb_plane_OnPieceOut, target) == 168, "bb_plane_OnPieceOut.target: offset"); BB_ASSERT(offsetof(bb_plane_OnPieceOut, need) == 176, "bb_plane_OnPieceOut.need: offset"); BB_ASSERT(offsetof(bb_plane_OnPieceOut, fault) == 180, "bb_plane_OnPieceOut.fault: offset"); -BB_ASSERT(offsetof(bb_plane_OnPieceOut, _fault_reserved) == 181, "bb_plane_OnPieceOut._fault_reserved: offset"); +BB_ASSERT(offsetof(bb_plane_OnPieceOut, tap_fault) == 181, "bb_plane_OnPieceOut.tap_fault: offset"); +BB_ASSERT(offsetof(bb_plane_OnPieceOut, _fault_reserved) == 182, "bb_plane_OnPieceOut._fault_reserved: offset"); BB_ASSERT(offsetof(bb_plane_OnPieceOut, lane) == 184, "bb_plane_OnPieceOut.lane: offset"); BB_ASSERT(offsetof(bb_plane_OnPieceOut, final_status) == 192, "bb_plane_OnPieceOut.final_status: offset"); BB_ASSERT(offsetof(bb_plane_OnPieceOut, _final_reserved) == 196, "bb_plane_OnPieceOut._final_reserved: offset"); diff --git a/crates/busbar-contract/src/abi/hot/host.rs b/crates/busbar-contract/src/abi/hot/host.rs index 75712da13f..7e9f198f78 100644 --- a/crates/busbar-contract/src/abi/hot/host.rs +++ b/crates/busbar-contract/src/abi/hot/host.rs @@ -535,12 +535,13 @@ pub type CounterAddFn = extern "C-unwind" fn( // ── THE HOST SERVICES (minor 30; ARCHITECT SD-3 queue (6), DEC-SERVE G2). The contract ports a // codec reaches — `busbar_contract::codec::{fill_entropy, wall_clock_now, -// usage_tap_fault_should_warn, max_translate_body_bytes}` — are process-wide `OnceLock`s the host -// arms in ITS image. A plane dropped in as a `cdylib` carries its own copy of the contract, whose -// ports nothing armed, so it read the failure path of every one. These four slots carry the -// host's own services across the seam; the dropped-in door arms the plane image's ports over -// them (`services::arm`) when the host opens the door. Process-wide, so `host` may be -// `HostCtx::NULL`. ── +// max_translate_body_bytes}` — are process-wide `OnceLock`s the host arms in ITS image. A plane +// dropped in as a `cdylib` carries its own copy of the contract, whose ports nothing armed, so it +// read the failure path of every one. These slots carry the host's own services across the seam; +// the dropped-in door arms the plane image's ports over them (`services::arm`) when the host +// opens the door. The usage-tap fault latch is RETIRED (row 155): the fault crosses as data on +// the plane's answer (`abi::plane::OnPieceOut::tap_fault`), so no host serves its slot and no +// plane calls it. Process-wide, so `host` may be `HostCtx::NULL`. ── /// Fill `out_len` bytes at `out` from the host's entropy source (its CSPRNG). [`StatusClass::Ok`] /// when every byte was written; [`StatusClass::Refused`] when the host has no entropy to give or /// `out` is NULL with a non-zero length (the bytes are then unspecified); [`StatusClass::Fault`] on @@ -550,9 +551,9 @@ pub type EntropyFillFn = /// The host's wall clock in whole seconds since the Unix epoch; `0` when the host holds no clock /// (the fail-closed reading, never a real second). pub type WallClockFn = extern "C-unwind" fn(host: HostCtx) -> u64; -/// Count one usage-tap fault of `reason` for `protocol` (two borrowed UTF-8 ranges, live for the -/// call) and answer `true` only the FIRST time the host sees that pair, so the caller warns once. -/// `false` for a range that is not UTF-8 or a caught panic. +/// RETIRED (row 155): the slot's shape, kept so the table's layout holds. A usage-tap fault is data +/// on the plane's answer (`abi::plane::OnPieceOut::tap_fault`), counted by the kernel; no host +/// serves this slot and no plane calls it. pub type TapFaultLatchFn = extern "C-unwind" fn( host: HostCtx, protocol_ptr: *const u8, @@ -714,7 +715,7 @@ pub struct PlaneHostVtable { pub entropy_fill: Option, /// Read the host's wall clock (whole Unix seconds). pub wall_clock: Option, - /// Count a usage-tap fault and answer whether it is the first of its kind. + /// RETIRED (row 155): always `None`; the usage-tap fault is data on the plane's answer. pub tap_fault_latch: Option, /// Read the operator's live translation cap. pub translate_cap: Option, @@ -929,13 +930,12 @@ impl PlaneHostVtable { translate_cap: None, }; - /// [`EMPTY`](Self::EMPTY) plus the four HOST SERVICES, each served from THIS image's own armed + /// [`EMPTY`](Self::EMPTY) plus the three HOST SERVICES, each served from THIS image's own armed /// contract ports ([`super::services`]). A host grants them by building its table over this /// (`..PlaneHostVtable::SERVICES`): the services are the contract's, so the host adds no code. pub const SERVICES: PlaneHostVtable = PlaneHostVtable { entropy_fill: Some(super::services::entropy_fill), wall_clock: Some(super::services::wall_clock), - tap_fault_latch: Some(super::services::tap_fault_latch), translate_cap: Some(super::services::translate_cap), ..Self::EMPTY }; diff --git a/crates/busbar-contract/src/abi/hot/services.rs b/crates/busbar-contract/src/abi/hot/services.rs index 2a0eab4681..c1957aefc4 100644 --- a/crates/busbar-contract/src/abi/hot/services.rs +++ b/crates/busbar-contract/src/abi/hot/services.rs @@ -4,19 +4,20 @@ //! THE HOST SERVICES ACROSS THE HOT SEAM (minor 30; ARCHITECT SD-3 queue (6), DEC-SERVE G2). //! //! The contract ports a codec reaches — [`fill_entropy`](crate::codec::fill_entropy), -//! [`wall_clock_now`](crate::codec::wall_clock_now), -//! [`usage_tap_fault_should_warn`](crate::codec::usage_tap_fault_should_warn) and +//! [`wall_clock_now`](crate::codec::wall_clock_now) and //! [`max_translate_body_bytes`](crate::codec::max_translate_body_bytes) — are process-wide //! `OnceLock`s the host arms in ITS image. A plane LINKED into the host shares that image, so it //! reads the host's services. A plane DROPPED IN as a `cdylib` carries its own copy of this crate, //! whose ports nothing armed: it read the failure path of every one (a synthesized id with no -//! entropy, an unstamped time, a latch that never warns, the default cap instead of the operator's). +//! entropy, an unstamped time, the default cap instead of the operator's). A usage-tap fault is no +//! port: the plane returns it as data on its answer (`abi::plane::OnPieceOut::tap_fault`), and the +//! `tap_fault_latch` slot is served and armed by no one. //! //! Two halves, one file, because they are one contract: //! -//! * **HOST side** — `entropy_fill`, `wall_clock`, `tap_fault_latch`, `translate_cap`: the -//! four slots of [`PlaneHostVtable::SERVICES`], each answering from THIS image's own ports, which -//! the host armed. A host that builds its table over `SERVICES` adds no code of its own. +//! * **HOST side** — `entropy_fill`, `wall_clock`, `translate_cap`: the three service slots of +//! [`PlaneHostVtable::SERVICES`], each answering from THIS image's own ports, which the host +//! armed. A host that builds its table over `SERVICES` adds no code of its own. //! * **PLANE side** — [`arm`]: run in a dropped-in plane's image (the door's `busbar_plane_arm` //! symbol, which the host calls when it opens the door), it installs that image's ports over the //! host's slots, so a codec in the plane reads the host's services exactly as a linked codec does. @@ -26,7 +27,7 @@ use super::pod::StatusClass; use std::panic::catch_unwind; use std::sync::atomic::{AtomicPtr, AtomicU32, Ordering}; -// ── HOST SIDE: the four slots, served from this image's armed contract ports ──────────────────────── +// ── HOST SIDE: the three slots, served from this image's armed contract ports ─────────────────────── /// `entropy_fill` — fill `out_len` bytes at `out` through [`crate::codec::fill_entropy`]. pub(crate) extern "C-unwind" fn entropy_fill( @@ -59,78 +60,12 @@ pub(crate) extern "C-unwind" fn wall_clock(_host: HostCtx) -> u64 { catch_unwind(|| crate::codec::wall_clock_now().unwrap_or(0)).unwrap_or(0) } -/// `tap_fault_latch` — [`crate::codec::usage_tap_fault_should_warn`] over the two borrowed ranges. -/// The port takes the reason as `&'static str` (a codec names its reasons as constants), so a -/// reason crossing from a plane image is kept once per distinct spelling, up to [`REASON_CAP`]; -/// past the cap every new spelling counts as [`UNLISTED_REASON`], so a plane cannot grow the host's -/// memory or its label set without bound. -pub(crate) extern "C-unwind" fn tap_fault_latch( - _host: HostCtx, - protocol_ptr: *const u8, - protocol_len: usize, - reason_ptr: *const u8, - reason_len: usize, -) -> bool { - catch_unwind(|| { - // SAFETY (both): each range is live and initialized for the call (the slot's discipline). - let (Some(protocol), Some(reason)) = - (unsafe { utf8(protocol_ptr, protocol_len) }, unsafe { - utf8(reason_ptr, reason_len) - }) - else { - return false; - }; - crate::codec::usage_tap_fault_should_warn(protocol, kept(reason)) - }) - .unwrap_or(false) -} - /// `translate_cap` — [`crate::codec::max_translate_body_bytes`]. pub(crate) extern "C-unwind" fn translate_cap(_host: HostCtx) -> u64 { catch_unwind(|| crate::codec::max_translate_body_bytes() as u64) .unwrap_or(crate::codec::TRANSLATE_BODY_MAX_BYTES_DEFAULT as u64) } -/// How many distinct usage-tap reasons the host keeps for planes (a codec declares a handful). -pub const REASON_CAP: usize = 64; - -/// The reason every spelling past [`REASON_CAP`] counts as. -pub const UNLISTED_REASON: &str = "unlisted"; - -/// `reason`, kept for the life of the process (once per spelling; see [`tap_fault_latch`]), in a -/// fixed table of [`REASON_CAP`] process-lifetime slots — nothing is leaked, and the table cannot grow. -fn kept(reason: &str) -> &'static str { - static KEPT: [std::sync::OnceLock; REASON_CAP] = - [const { std::sync::OnceLock::new() }; REASON_CAP]; - static FILLING: std::sync::Mutex<()> = std::sync::Mutex::new(()); - let _filling = FILLING - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - for slot in &KEPT { - match slot.get() { - Some(kept) if kept == reason => return kept.as_str(), - Some(_) => {} - None => return slot.get_or_init(|| reason.to_owned()).as_str(), - } - } - UNLISTED_REASON -} - -/// A borrowed range as UTF-8; `None` for a NULL range with a length, or bytes that are not UTF-8. -/// -/// # Safety -/// A non-null `ptr` must address `len` live, initialized bytes for `'a`. -unsafe fn utf8<'a>(ptr: *const u8, len: usize) -> Option<&'a str> { - if len == 0 { - return Some(""); - } - if ptr.is_null() { - return None; - } - // SAFETY: the caller's obligation. - std::str::from_utf8(unsafe { core::slice::from_raw_parts(ptr, len) }).ok() -} - // ── PLANE SIDE: arm this image's ports over the host's slots ──────────────────────────────────────── /// The host table [`arm`] was handed, and the size [`PlaneHostVtable::check`] honoured for it. @@ -168,16 +103,13 @@ pub unsafe fn arm(host: *const PlaneHostVtable) -> StatusClass { } ARMED_SIZE.store(size, Ordering::Release); ARMED.store(host.cast_mut(), Ordering::Release); - // SAFETY (all four): `host`/`size` are what `check` just validated. + // SAFETY (all three): `host`/`size` are what `check` just validated. if crate::host_slot!(host, size, entropy_fill).is_some() { crate::codec::install_entropy_source(armed_entropy); } if crate::host_slot!(host, size, wall_clock).is_some() { crate::codec::install_wall_clock(armed_wall_clock); } - if crate::host_slot!(host, size, tap_fault_latch).is_some() { - crate::codec::install_usage_tap_fault_latch(armed_tap_fault_latch); - } if crate::host_slot!(host, size, translate_cap).is_some() { crate::codec::install_translate_cap_reader(armed_translate_cap); } @@ -210,22 +142,6 @@ fn armed_wall_clock() -> u64 { .map_or(0, |now| now(HostCtx::NULL)) } -/// The usage-tap latch port, forwarded through the host's `tap_fault_latch`. -fn armed_tap_fault_latch(protocol: &str, reason: &'static str) -> bool { - // SAFETY: as `armed_entropy`. - armed() - .and_then(|(host, size)| crate::host_slot!(host, size, tap_fault_latch)) - .is_some_and(|latch| { - latch( - HostCtx::NULL, - protocol.as_ptr(), - protocol.len(), - reason.as_ptr(), - reason.len(), - ) - }) -} - /// The translate-cap port, forwarded through the host's `translate_cap`. fn armed_translate_cap() -> usize { // SAFETY: as `armed_entropy`. diff --git a/crates/busbar-contract/src/abi/hot/tests/services_tests.rs b/crates/busbar-contract/src/abi/hot/tests/services_tests.rs index 358b532142..844181262b 100644 --- a/crates/busbar-contract/src/abi/hot/tests/services_tests.rs +++ b/crates/busbar-contract/src/abi/hot/tests/services_tests.rs @@ -14,15 +14,6 @@ extern "C-unwind" fn host_entropy(_host: HostCtx, out: *mut u8, len: usize) -> S extern "C-unwind" fn host_clock(_host: HostCtx) -> u64 { 1_700_000_000 } -extern "C-unwind" fn host_latch( - _host: HostCtx, - _protocol_ptr: *const u8, - protocol_len: usize, - _reason_ptr: *const u8, - reason_len: usize, -) -> bool { - protocol_len == 5 && reason_len == 6 -} extern "C-unwind" fn host_cap(_host: HostCtx) -> u64 { 4_096 } @@ -57,7 +48,6 @@ fn an_armed_image_reads_the_hosts_services_through_every_port() { static HOST: PlaneHostVtable = PlaneHostVtable { entropy_fill: Some(host_entropy), wall_clock: Some(host_clock), - tap_fault_latch: Some(host_latch), translate_cap: Some(host_cap), ..PlaneHostVtable::EMPTY }; @@ -66,11 +56,6 @@ fn an_armed_image_reads_the_hosts_services_through_every_port() { assert!(crate::codec::fill_entropy(&mut drawn)); assert_eq!(drawn, [0xA5; 4], "the draw is the host's"); assert_eq!(crate::codec::wall_clock_now(), Some(1_700_000_000)); - assert!(crate::codec::usage_tap_fault_should_warn("proto", "reason")); - assert!(!crate::codec::usage_tap_fault_should_warn( - "proto", - "other-reason" - )); assert_eq!(crate::codec::max_translate_body_bytes(), 4_096); // The HOST side of the same seam: `SERVICES` answers from this image's (now armed) ports. @@ -93,31 +78,6 @@ fn an_armed_image_reads_the_hosts_services_through_every_port() { (PlaneHostVtable::SERVICES.translate_cap.unwrap())(HostCtx::NULL), 4_096 ); - let latch = PlaneHostVtable::SERVICES.tap_fault_latch.unwrap(); - let (p, r) = ("proto", "reason"); - assert!(latch( - HostCtx::NULL, - p.as_ptr(), - p.len(), - r.as_ptr(), - r.len() - )); - let invalid = [0xFFu8]; - assert!( - !latch(HostCtx::NULL, p.as_ptr(), p.len(), invalid.as_ptr(), 1), - "not UTF-8" - ); - - // Past `REASON_CAP` distinct spellings, every new reason is kept as `UNLISTED_REASON`, so a - // plane cannot grow the host's memory or label set without bound. (Here, after every latch - // assertion above: the kept set is process-wide.) - for i in 0..REASON_CAP { - let _ = kept(&format!("reason-{i}")); - } - assert_eq!(kept("one-past-the-cap"), UNLISTED_REASON); - assert_eq!( - kept("reason-0"), - "reason-0", - "a kept spelling is still found" - ); + // The usage-tap latch slot is RETIRED (row 155): `SERVICES` serves it no longer. + assert!(PlaneHostVtable::SERVICES.tap_fault_latch.is_none()); } diff --git a/crates/busbar-contract/src/abi/plane/check.rs b/crates/busbar-contract/src/abi/plane/check.rs index 3a27cc3279..e2b94ed41a 100644 --- a/crates/busbar-contract/src/abi/plane/check.rs +++ b/crates/busbar-contract/src/abi/plane/check.rs @@ -33,7 +33,7 @@ use super::{ MECHANISM_PEER_KEY, MECHANISM_ROOT, PIECE_OUT_TEXT, PIN_FINGERPRINT, PRINCIPAL_OPTIONAL, RECORD_AUDIT, RECORD_PUT, REFUSAL_ANY_DIALECT, ROUTE_COUNTED, ROUTE_DIRECT, ROUTE_LOCAL, ROUTE_ONCE, ROUTE_POOL, ROUTE_PUBLIC, ROUTE_SCOPE, ROUTE_SESSION, ROUTE_STREAM, - SHAPE_PIECEWISE, SHAPE_WHOLE, TAIL_FALLBACK, TAIL_HOOKS_GATED, TAIL_PROBES, + SHAPE_PIECEWISE, SHAPE_WHOLE, TAIL_FALLBACK, TAIL_HOOKS_GATED, TAIL_PROBES, TAP_FAULT_NONE, TRUST_ITEM_APPROVALS, TRUST_PIN, TRUST_PRIVATE_REACH, UNITS_ESTIMATED, VERDICT_HARD, }; use crate::abi::hook::{ @@ -469,7 +469,9 @@ fn verdict(outcome: Outcome, v: u32) -> Result<(), Fault> { } /// `on_piece`'s breaker fault reading: a known `FAULT_*` (the transport kind's one vocabulary), none -/// on an answer that is not READY, and its padding zero. +/// on an answer that is not READY, and its padding zero. Its usage-tap fault rides only a READY +/// answer; any value is admitted there, because the kernel counts a value outside the `TAP_FAULT_*` +/// vocabulary as `unknown` rather than refusing the answer it rides. fn fault_reading(outcome: Outcome, out: &OnPieceOut) -> Result<(), Fault> { use crate::abi::transport::{FAULT_HARD, FAULT_NONE}; code( @@ -481,7 +483,10 @@ fn fault_reading(outcome: Outcome, out: &OnPieceOut) -> Result<(), Fault> { if out.fault != FAULT_NONE && outcome != Outcome::Ready { return Err(fault(Rule::Contradiction, "on_piece.fault_not_ready")); } - if out._fault_reserved != [0; 3] { + if out.tap_fault != TAP_FAULT_NONE && outcome != Outcome::Ready { + return Err(fault(Rule::Contradiction, "on_piece.tap_fault_not_ready")); + } + if out._fault_reserved != [0; 2] { return Err(fault(Rule::Contradiction, "on_piece.fault_reserved")); } Ok(()) diff --git a/crates/busbar-contract/src/abi/plane/mod.rs b/crates/busbar-contract/src/abi/plane/mod.rs index 325fefc8a5..2ce74f5e5a 100644 --- a/crates/busbar-contract/src/abi/plane/mod.rs +++ b/crates/busbar-contract/src/abi/plane/mod.rs @@ -373,6 +373,16 @@ pub const UNITS_REPORTED: u32 = 1; /// [`UNITS_REPORTED`]; the plane decides when it applies, the kernel adds no floor of its own. pub const UNITS_FLOOR: u32 = 2; +/// [`OnPieceOut::tap_fault`]: no usage-tap fault. +pub const TAP_FAULT_NONE: u8 = 0; +/// [`OnPieceOut::tap_fault`]: the cell's own reader refused the body (`reason="decode"`). +pub const TAP_FAULT_DECODE: u8 = 1; +/// [`OnPieceOut::tap_fault`]: the body is not JSON (`reason="bad_json"`). +pub const TAP_FAULT_BAD_JSON: u8 = 2; +/// [`OnPieceOut::tap_fault`]: the caller's dialect is not one the tap reads +/// (`reason="unknown_protocol"`). +pub const TAP_FAULT_UNKNOWN_PROTOCOL: u8 = 3; + /// THE ONE BILLING RULE ON A COUNT'S SOURCE: [`UNITS_REPORTED`] and [`UNITS_FLOOR`] bill; /// [`UNITS_ESTIMATED`] (and any other value) never does. Every kernel site that ledgers, meters or /// reads a fee unit from a plane's counts asks this, never a source compare of its own. @@ -1795,8 +1805,17 @@ pub struct OnPieceOut { /// may still be the destination's. `FAULT_NONE` = no reading. Laid in the padding after /// `need` (pre-tag v1). pub fault: u8, + /// THE UNIT'S USAGE-TAP FAULT (`TAP_FAULT_*`), beside the units this answer reports: why the + /// plane's usage tap could not read a same-protocol 2xx body, so the request billed 0 tokens + /// ([`crate::codec::UsageTapFault`]). The kernel counts it, once per answer that carries it, on + /// `busbar_billing_tap_decode_fail_total{protocol,reason}` under the unit's dialect, and warns + /// once per `(protocol, reason)`: the fault crosses as data, never as a host call. A CLOSED + /// enum: [`TAP_FAULT_NONE`] = none, then the named reasons; any other value is counted under + /// `reason="unknown"` (and warned once), never ignored and never read as none. Laid in the + /// padding after `fault` (pre-release, at the unchanged plane ABI version). + pub tap_fault: u8, /// Alignment padding. - pub _fault_reserved: [u8; 3], + pub _fault_reserved: [u8; 2], /// THE UNIT'S LEDGER LANE, in the arena; a zero length = none named. The billing identity the /// unit's units are priced, ledgered and metered under, which is not the route entry the walk /// picked (a call of one tool on a pooled server is the tool's lane, not the server's). The diff --git a/crates/busbar-contract/src/abi/tests/plane_check_tests.rs b/crates/busbar-contract/src/abi/tests/plane_check_tests.rs index 60d328245f..8598675bf0 100644 --- a/crates/busbar-contract/src/abi/tests/plane_check_tests.rs +++ b/crates/busbar-contract/src/abi/tests/plane_check_tests.rs @@ -1675,13 +1675,44 @@ fn a_fault_reading_is_the_transports_vocabulary_on_a_ready_answer() { f(Rule::Contradiction, "on_piece.fault_not_ready") ); o.fault = FAULT_NONE; - o._fault_reserved = [0, 1, 0]; + o._fault_reserved = [0, 1]; assert_eq!( piece(&o, &[], &[], &[]), f(Rule::Contradiction, "on_piece.fault_reserved") ); } +/// THE USAGE-TAP FAULT rides the answer as data (row 155), beside the breaker reading and separate +/// from it, on a READY answer only. A value past the vocabulary is ADMITTED: the kernel counts it +/// as `unknown` rather than refusing the answer it rides. RED arm: a fault on a non-READY answer. +#[test] +fn a_usage_tap_fault_rides_only_a_ready_answer() { + use crate::abi::plane::{ + TAP_FAULT_BAD_JSON, TAP_FAULT_DECODE, TAP_FAULT_NONE, TAP_FAULT_UNKNOWN_PROTOCOL, + }; + let mut o: OnPieceOut = z(); + for code in [ + TAP_FAULT_NONE, + TAP_FAULT_DECODE, + TAP_FAULT_BAD_JSON, + TAP_FAULT_UNKNOWN_PROTOCOL, + ] { + o.tap_fault = code; + assert_eq!(piece(&o, &[], &[], &[]), Ok(()), "{code}"); + } + o.tap_fault = TAP_FAULT_UNKNOWN_PROTOCOL + 1; + assert_eq!( + piece(&o, &[], &[], &[]), + Ok(()), + "counted as unknown, never refused" + ); + o.tap_fault = TAP_FAULT_DECODE; + assert_eq!( + check_on_piece(Pending, &o, (&[], &[], &[]), &caps(), &bounds()), + f(Rule::Contradiction, "on_piece.tap_fault_not_ready") + ); +} + #[test] fn a_verdict_rides_only_a_ready_answer() { let mut o: OnPieceOut = z(); diff --git a/crates/busbar-contract/src/codec.rs b/crates/busbar-contract/src/codec.rs index ebdc32cdc5..c33376b05e 100644 --- a/crates/busbar-contract/src/codec.rs +++ b/crates/busbar-contract/src/codec.rs @@ -123,53 +123,84 @@ pub enum CodecError { Malformed(String), } -/// The host's USAGE-TAP FAULT REPORTER: told when a cell's own reader refused a same-protocol 2xx body -/// in [`OperationHandler::extract_usage`]'s default, so the request billed 0 tokens. The host counts -/// it (a per-request volume signal) and warns once per `(protocol, reason)`; the contract carries -/// only the shape of the call, because counting and logging are the host's, not the cell's. -pub type UsageTapFaultReporter = fn(ingress_protocol: &str, error: &CodecError); - -static USAGE_TAP_FAULT_REPORTER: std::sync::OnceLock = - std::sync::OnceLock::new(); - -/// Install the host's [`UsageTapFaultReporter`]. The first install wins and later ones are ignored: -/// one process has one host. The host installs it where it installs the protocols whose cells report -/// through it, so no cell is reachable before it is armed. -pub fn install_usage_tap_fault_reporter(reporter: UsageTapFaultReporter) { - let _ = USAGE_TAP_FAULT_REPORTER.set(reporter); +/// A USAGE-TAP FAULT: why a cell's usage tap could not read a same-protocol 2xx body, so the request +/// billed 0 tokens. It is DATA the tap returns ([`OperationHandler::extract_usage`]'s `Err`): the +/// plane carries it out on its door's answer beside the units it reports +/// ([`crate::abi::plane::OnPieceOut::tap_fault`]), and the kernel counts it (one increment of +/// `busbar_billing_tap_decode_fail_total{protocol,reason}`) and warns once per `(protocol, reason)`. +/// The contract carries only the shape: no port, no latch and no static, so a codec reaches no host +/// to report it and holds nothing that counts it. +/// +/// ```compile_fail +/// // There is no host port to report through: the fault is returned, never reported. +/// busbar_contract::codec::report_usage_tap_decode_failure( +/// "proto", +/// &busbar_contract::codec::CodecError::Malformed(String::new()), +/// ); +/// ``` +/// +/// ```compile_fail +/// // And no latch a codec asks whether to warn: the kernel's count is the only one. +/// let _ = busbar_contract::codec::usage_tap_fault_should_warn("proto", "decode"); +/// ``` +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum UsageTapFault { + /// The cell's own reader refused the body (`decode`). + Decode, + /// The body is not JSON (`bad_json`). + BadJson, + /// The caller's dialect is not one the tap reads (`unknown_protocol`). + UnknownProtocol, } -/// Report a usage-tap decode failure to the installed host reporter; a no-op in a process whose host -/// installed none (a bare codec test). -pub fn report_usage_tap_decode_failure(ingress_protocol: &str, error: &CodecError) { - if let Some(report) = USAGE_TAP_FAULT_REPORTER.get() { - report(ingress_protocol, error); +impl UsageTapFault { + /// The `reason` label the kernel counts the fault under. + #[must_use] + pub const fn reason(self) -> &'static str { + match self { + UsageTapFault::Decode => "decode", + UsageTapFault::BadJson => "bad_json", + UsageTapFault::UnknownProtocol => "unknown_protocol", + } } -} -/// The host's USAGE-TAP FAULT LATCH: counts one usage-tap fault of `reason` for `ingress_protocol` -/// (a per-request volume signal) and answers `true` only the FIRST time the host sees that -/// `(protocol, reason)`, so a codec that raises its own coded diagnostic for the fault warns once and -/// logs at debug after. [`UsageTapFaultReporter`] is this latch plus the one decode-failure line a -/// cell's default tap prints; a codec whose tap reads a body in more than one way reports each way's -/// reason through the latch and prints its own line. Counting is the host's, not the codec's. -pub type UsageTapFaultLatch = fn(ingress_protocol: &str, reason: &'static str) -> bool; - -static USAGE_TAP_FAULT_LATCH: std::sync::OnceLock = std::sync::OnceLock::new(); + /// The fault's code on the door's answer ([`crate::abi::plane::TAP_FAULT_DECODE`] and on). + #[must_use] + pub const fn code(self) -> u8 { + match self { + UsageTapFault::Decode => crate::abi::plane::TAP_FAULT_DECODE, + UsageTapFault::BadJson => crate::abi::plane::TAP_FAULT_BAD_JSON, + UsageTapFault::UnknownProtocol => crate::abi::plane::TAP_FAULT_UNKNOWN_PROTOCOL, + } + } -/// Install the host's [`UsageTapFaultLatch`]. First install wins, exactly as -/// [`install_usage_tap_fault_reporter`], and the host installs both in the same place. -pub fn install_usage_tap_fault_latch(latch: UsageTapFaultLatch) { - let _ = USAGE_TAP_FAULT_LATCH.set(latch); -} + /// The reason the kernel counts a door's answer code under, the byte read as the CLOSED enum + /// it is: `None` only for [`crate::abi::plane::TAP_FAULT_NONE`]; a named code's + /// [`Self::reason`]; and every other value [`Self::UNKNOWN_REASON`]. A byte outside the + /// vocabulary is never ignored and never read as none: it is counted, and warned once. + #[must_use] + pub const fn reason_of(code: u8) -> Option<&'static str> { + match Self::of_code(code) { + Some(fault) => Some(fault.reason()), + None if code == crate::abi::plane::TAP_FAULT_NONE => None, + None => Some(Self::UNKNOWN_REASON), + } + } -/// Count one usage-tap fault through the installed host latch and answer whether this is the first -/// of its `(protocol, reason)`. In a process whose host installed none (a bare codec test) nothing is -/// counted and the answer is `false`, so the caller logs at debug. -pub fn usage_tap_fault_should_warn(ingress_protocol: &str, reason: &'static str) -> bool { - USAGE_TAP_FAULT_LATCH - .get() - .is_some_and(|latch| latch(ingress_protocol, reason)) + /// The `reason` a tap-fault byte outside the vocabulary is counted under. + pub const UNKNOWN_REASON: &'static str = "unknown"; + + /// The fault a door's answer code names; `None` for [`crate::abi::plane::TAP_FAULT_NONE`] and + /// for a code outside the vocabulary (which [`Self::reason_of`] counts as `unknown`). + #[must_use] + pub const fn of_code(code: u8) -> Option { + match code { + crate::abi::plane::TAP_FAULT_DECODE => Some(UsageTapFault::Decode), + crate::abi::plane::TAP_FAULT_BAD_JSON => Some(UsageTapFault::BadJson), + crate::abi::plane::TAP_FAULT_UNKNOWN_PROTOCOL => Some(UsageTapFault::UnknownProtocol), + _ => None, + } + } } /// The host's TRANSLATE-BODY CAP READER: the operator's per-response translation cap, in bytes, as @@ -329,16 +360,19 @@ pub trait OperationHandler: Send + Sync { /// end, only when [`Self::taps_usage`] is true). Default: run THIS operation's own reader over the /// body and project its token usage — so a token-metered non-chat op (embeddings) bills the same /// as the cross-protocol path. Chat overrides this to run the egress protocol's chat reader. - fn extract_usage(&self, ingress_protocol: &str, body: &[u8]) -> Option { + /// + /// `Err` is the [`UsageTapFault`] that kept the tap from reading the body: the request bills 0 + /// tokens, and the fault is returned as data for the kernel to count, never reported from here. + fn extract_usage( + &self, + _ingress_protocol: &str, + body: &[u8], + ) -> Result, UsageTapFault> { + // A same-protocol 2xx body the op's own codec cannot decode is a `decode` fault (like the + // cross-protocol seam), never a silent 0-token bill with no operator signal. match self.read_response(body) { - Ok(r) => r.token_usage(), - Err(e) => { - // A same-protocol 2xx body the op's own codec cannot decode: record it (like the - // cross-protocol seam) rather than silently bill 0 tokens with no operator signal. - // The host's reporter counts it and warns once per (protocol, reason). - report_usage_tap_decode_failure(ingress_protocol, &e); - None - } + Ok(r) => Ok(r.token_usage()), + Err(_) => Err(UsageTapFault::Decode), } } /// The Content-Type of THIS operation's egress request wire (what `write_request` emits). diff --git a/crates/busbar-contract/src/transport/transport.rs b/crates/busbar-contract/src/transport/transport.rs index 5d53895310..0b6b944f91 100644 --- a/crates/busbar-contract/src/transport/transport.rs +++ b/crates/busbar-contract/src/transport/transport.rs @@ -188,8 +188,8 @@ pub enum Transport { /// property the transport can assume. ARMED under the `runtime` capability: the neutral WS /// transport is real on both legs — the ingress WS-upgrade acceptor (`crate::ingress::duplex_ws`) /// presents an upgraded socket as the `serve_messages` channel, and the egress WS dialer - /// (`crate::egress::duplex_ws`) dials an upstream `wss://` THROUGH `net_guard` and hands back the - /// same channel. A duplex caller selects this variant and lets the transport open the socket. + /// (`crate::egress::duplex_ws`) dials an upstream `wss://` THROUGH the egress unit's guard and + /// hands back the same channel. A duplex caller selects this variant and lets the transport open the socket. WebSocket, } diff --git a/crates/busbar-contract/tests/golden/abi-layout.golden b/crates/busbar-contract/tests/golden/abi-layout.golden index d613b94692..cceeeb28ea 100644 --- a/crates/busbar-contract/tests/golden/abi-layout.golden +++ b/crates/busbar-contract/tests/golden/abi-layout.golden @@ -2099,7 +2099,8 @@ pkind::OnPieceOut.verb=160 pkind::OnPieceOut.target=168 pkind::OnPieceOut.need=176 pkind::OnPieceOut.fault=180 -pkind::OnPieceOut._fault_reserved=181 +pkind::OnPieceOut.tap_fault=181 +pkind::OnPieceOut._fault_reserved=182 pkind::OnPieceOut.lane=184 pkind::OnPieceOut.final_status=192 pkind::OnPieceOut._final_reserved=196 diff --git a/crates/busbar-contract/tests/layout_golden.rs b/crates/busbar-contract/tests/layout_golden.rs index 3a86dc8e1c..38b277a985 100644 --- a/crates/busbar-contract/tests/layout_golden.rs +++ b/crates/busbar-contract/tests/layout_golden.rs @@ -1850,6 +1850,7 @@ fn compute_layout() -> String { target, need, fault, + tap_fault, _fault_reserved, lane, final_status, diff --git a/crates/busbar-core-connector/Cargo.toml b/crates/busbar-core-connector/Cargo.toml index 079fe96c80..a0930e86fa 100644 --- a/crates/busbar-core-connector/Cargo.toml +++ b/crates/busbar-core-connector/Cargo.toml @@ -33,6 +33,11 @@ busbar-contract = { path = "../busbar-contract" } # config and implements its `DestJudge` trait with the one destination guard (`guard`, `process`); # `busbar-kernel` names nothing here. busbar-kernel = { path = "../busbar-kernel", default-features = false } +# The egress unit's one http(s) URL reader (`trust::net::split_url`, the destination judge's URL arm +# and the metadata carve-outs' host), and its `Resolver` seam, which the system name lookup the +# kernel's remaining resolve-then-pin dials ask (`hostio::SystemNames`) implements: the kernel +# resolves no name itself (row 130 E2). One-way, like the edge above. +busbar-kernel-egress = { path = "../busbar-kernel-egress" } rustls = { workspace = true } rustls-pki-types = { workspace = true } tokio-rustls = { workspace = true } diff --git a/crates/busbar-core-connector/src/datagram.rs b/crates/busbar-core-connector/src/datagram.rs new file mode 100644 index 0000000000..e266a36500 --- /dev/null +++ b/crates/busbar-core-connector/src/datagram.rs @@ -0,0 +1,1072 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (C) 2026 Busbar Inc and contributors + +//! THE DATAGRAM DRIVER: one association's datagram framer, driven host-side over the host's port +//! and secure layer (`BUSBAR-1.6.0.md` THE DESIGN §5, "Datagram media", l.758-766). +//! +//! The port is the host's ([`crate::udp`]) and so is the secure layer ([`crate::dtls`]); a framer +//! whose tail states `FRAMING_DATAGRAM` holds what is left. This driver stands between them for ONE +//! association and carries THE DATAGRAM LANE (`busbar_contract::abi::transport::datagram`) on every +//! call it makes: +//! +//! * PATHS ARE HOST NUMBERS. A far-end address gets a nonzero number the first time the driver +//! hands up a datagram from it, and the framer is told it once, in the lane's `paths`. Only an +//! address the secure layer knows (a verified check came from it, or core's own probe proved it) +//! is ever handed up; anything else from the port stops here. +//! * THE RENDEZVOUS. The far end's description goes in on the rendezvous lane. The framer answers +//! its own description as a piece and the terms; the terms build the secure layer's association +//! (the handshake role, both ends' check credentials and the far end's certificate fingerprint). +//! * DATAGRAMS go through the secure layer's demultiplexer first ([`Association::ingest`]): what it +//! hands up reaches the framer on the clear lane, and plaintext it opened on the secured lane. +//! * THE KEYING-MATERIAL ITEM. What the secure layer exported is presented ONCE, on the first call +//! after the export, in host memory valid for that call, and then dropped, which zeroes it. It is +//! never presented before the far end is verified: the secure layer exports nothing until the far +//! end's certificate matched its description, and the lane's `verified` bit says so on the same +//! call. +//! * ROUTES. A clear route leaves only where the secure layer allows it +//! ([`Association::may_send_clear`]: the answer to a check to a path that sent one, anything else +//! to the bound path); a secured route is sealed to the bound path. Any other route is dropped +//! and counted ([`DatagramFraming::refused_routes`]). +//! * PATH REQUESTS go to the secure layer, which binds only a path it has proven by its own round +//! trip; the lane's `bound` says where it stands on every call after. +//! * Every answer is judged (`check_datagram`) before any byte of it is used. +//! * THE DIALLING SIDE ([`DatagramFraming::dial`]) hears nothing before it sends: the far end's +//! description advertised its candidates, and each is judged by the host's ONE destination judge +//! ([`DialJudge`], under the dialling need's egress class) BEFORE any byte goes there. A candidate +//! the judge refuses is never numbered, never told to the framer and never sent to; one it admits +//! becomes an admitted candidate of the secure layer ([`Association::admit_candidate`]), which +//! probes it and binds it only once that probe is answered from it. The accepting side learns +//! its paths from verified checks alone and admits no candidate. +//! +//! Sans-IO: the caller hands in what the port read and the clock, takes +//! [`DatagramFraming::poll_send`] out to the port, and calls [`DatagramFraming::timeout`] at +//! [`DatagramFraming::deadline`]. + +use std::collections::{HashMap, VecDeque}; +use std::net::SocketAddr; +use std::sync::Arc; +use std::time::Instant; + +use busbar_contract::abi::mechanism::call::{AbiStr, Field, Outcome}; +use busbar_contract::abi::sdk::door::{blank_in, blank_out}; +use busbar_contract::abi::transport::check::check_datagram; +use busbar_contract::abi::transport::{ + BeginIn, ConnFacts, DatagramLane, DatagramPath, DatagramRoute, EmitIn, FinishIn, FramerOut, + FramingIn, IngestIn, KeyingMaterial, RendezvousTerms, CLOSE_NORMAL, EMIT_TEXT, + FINGERPRINT_BYTES, HANDSHAKE_ANSWERS, HANDSHAKE_INITIATES, HANDSHAKE_NONE, LANE_CLEAR, + LANE_RENDEZVOUS, LANE_SECURED, PATH_REQUEST_BIND, PATH_REQUEST_REBIND, SIDE_ACCEPT, SIDE_DIAL, +}; + +use crate::dtls::{Association, IceCredentials, Role, SessionCert, SrtpKeying, Up, Verdict}; +use crate::framer::{instant_of, ready, Buffers, Call, Crossed, FramerDoor, Refused, Yielded}; +use crate::DialJudge; + +/// The routes one framer answer may write. +const ROUTES_CAP: usize = 64; +/// The most far-end addresses one association numbers at once. +const MAX_PATHS: usize = 64; + +const NO_TEXT: AbiStr = AbiStr { + ptr: std::ptr::null(), + len: 0, +}; + +fn text(b: &[u8]) -> AbiStr { + if b.is_empty() { + return NO_TEXT; + } + AbiStr { + ptr: b.as_ptr(), + len: b.len(), + } +} + +fn fault(why: impl Into) -> Refused { + Refused { + outcome: Outcome::Fault, + error: why.into(), + } +} + +/// The host's numbers for the far-end addresses it has handed up. +#[derive(Debug, Default)] +struct PathBook { + by_addr: HashMap, + by_path: HashMap, + /// Numbered, not yet told to the framer. + untold: Vec, + last: u32, +} + +impl PathBook { + fn path(&self, addr: SocketAddr) -> u32 { + self.by_addr.get(&addr).copied().unwrap_or(0) + } + + fn addr(&self, path: u32) -> Option { + self.by_path.get(&path).copied() + } + + /// `addr`'s number, numbering it now if it has none. At the cap the numbers of addresses the + /// secure layer no longer knows make room (`keep` says which it still knows); a number is never + /// reused. + fn number(&mut self, addr: SocketAddr, keep: impl Fn(SocketAddr) -> bool) -> Option { + if let Some(p) = self.by_addr.get(&addr) { + return Some(*p); + } + if self.by_addr.len() >= MAX_PATHS { + let gone: Vec = + self.by_addr.keys().copied().filter(|a| !keep(*a)).collect(); + for a in gone { + if let Some(p) = self.by_addr.remove(&a) { + self.by_path.remove(&p); + self.untold.retain(|u| *u != p); + } + } + if self.by_addr.len() >= MAX_PATHS { + return None; + } + } + self.last = self.last.checked_add(1)?; + self.by_addr.insert(addr, self.last); + self.by_path.insert(self.last, addr); + self.untold.push(self.last); + Some(self.last) + } +} + +/// One framer op, as this driver makes it. +#[derive(Clone, Copy)] +enum Op<'a> { + /// The opening fields a dialling framing offers. + Begin(&'a [(String, Vec)]), + /// Bytes on a lane (`0` = a call that only carries the host's state); `end` = the far end + /// closed. + Ingest { + lane: u32, + from: Option, + bytes: &'a [u8], + end: bool, + }, + Emit { + stream: u64, + bytes: &'a [u8], + end_of_frame: bool, + text: bool, + }, + Timer, +} + +impl Op<'_> { + /// The re-call a `YIELD_MORE` answer asks for: the same op, no new bytes. + fn again(self) -> Self { + match self { + Op::Begin(_) | Op::Timer => Op::Timer, + Op::Ingest { .. } => Op::Ingest { + lane: 0, + from: None, + bytes: &[], + end: false, + }, + Op::Emit { stream, .. } => Op::Emit { + stream, + bytes: &[], + end_of_frame: false, + text: false, + }, + } + } +} + +/// What a framer answer asked of the host, copied out of its buffers. +#[derive(Default)] +struct Asked { + routes: Vec<(u32, u32, Vec)>, + request: (u32, u32, u32), + terms: Option, +} + +/// Rendezvous terms, owned. +struct Terms { + role: Role, + local: IceCredentials, + remote: IceCredentials, + fingerprint: [u8; 32], + /// The far end's advertised addresses, one per line, as the framer wrote them. + candidates: Vec, +} + +/// ONE ASSOCIATION'S DATAGRAM FRAMER, driven over the host's port and secure layer (module docs). +pub struct DatagramFraming { + door: Arc, + token: u64, + bufs: Buffers, + routes: Vec, + cert: Arc, + /// The host port's address, as text: the one candidate the framer advertises. + local: String, + secure: Option, + book: PathBook, + /// Exported by the secure layer, not yet presented. + keying: Option, + /// The keying-material item has been presented. + presented: bool, + /// The host state the framer was last told: the bound path and the verified bit. + told: (u32, bool), + send: VecDeque<(SocketAddr, Vec)>, + refused_routes: u64, + deadline_ns: Option, + ended: bool, + /// `SIDE_ACCEPT` or `SIDE_DIAL`. + side: u32, + /// The dialling side's destination judge and the need's egress class. + judge: Option<(Arc, u32)>, + refused_candidates: u64, + /// The user name the far end's checks carry first, once the terms settled it: the key the + /// port's demultiplexer finds this association by ([`Associations`]). + user: Option, +} + +impl std::fmt::Debug for DatagramFraming { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("DatagramFraming") + .field("entry", &self.door.facts().name) + .field("token", &self.token) + .field("local", &self.local) + .field("presented", &self.presented) + .finish_non_exhaustive() + } +} + +impl DatagramFraming { + /// Begin an ACCEPTING association on `door`: the host's certificate `cert` (the framer gets its + /// public DER, whose fingerprint its description states) and the host port's address `local`. + /// The far end's description follows through [`Self::rendezvous`]. + /// + /// # Errors + /// + /// The entry refused to frame it, or answered outside the lane's rules. + pub fn accept( + door: Arc, + cert: Arc, + local: SocketAddr, + ) -> Result<(Self, Yielded), Refused> { + Self::begin(door, cert, local, SIDE_ACCEPT, None, &[]) + } + + /// Begin a DIALLING association on `door`: as [`Self::accept`], with the `opening` fields the + /// framing offers (its own description comes back as a piece) and the destination `judge` every + /// candidate the far end's answer advertises meets, under the dialling need's egress `class`, + /// before any byte goes there. + /// + /// # Errors + /// + /// The entry refused to frame it, or answered outside the lane's rules. + pub fn dial( + door: Arc, + cert: Arc, + local: SocketAddr, + opening: &[(String, Vec)], + judge: Arc, + class: u32, + ) -> Result<(Self, Yielded), Refused> { + Self::begin(door, cert, local, SIDE_DIAL, Some((judge, class)), opening) + } + + fn begin( + door: Arc, + cert: Arc, + local: SocketAddr, + side: u32, + judge: Option<(Arc, u32)>, + opening: &[(String, Vec)], + ) -> Result<(Self, Yielded), Refused> { + let mut f = DatagramFraming { + door, + token: 0, + bufs: Buffers::for_side(side), + routes: vec![DatagramRoute::default(); ROUTES_CAP], + cert, + local: local.to_string(), + secure: None, + book: PathBook::default(), + keying: None, + presented: false, + told: (0, false), + send: VecDeque::new(), + refused_routes: 0, + deadline_ns: None, + ended: false, + side, + judge, + refused_candidates: 0, + user: None, + }; + let y = f.run(Op::Begin(opening), Instant::now())?; + Ok((f, y)) + } + + /// The far end's description. The framer's own description comes back as a piece, and the + /// terms it settled build the secure layer's association. + /// + /// # Errors + /// + /// The framer refused the description, or answered no terms for it. + pub fn rendezvous(&mut self, description: &[u8], now: Instant) -> Result { + let y = self.run( + Op::Ingest { + lane: LANE_RENDEZVOUS, + from: None, + bytes: description, + end: false, + }, + now, + )?; + if self.secure.is_none() { + return Err(fault("the framer answered a rendezvous with no terms")); + } + Ok(y) + } + + /// One datagram the port read from `from`. Before the rendezvous settled terms nothing on the + /// port is this association's, and it is dropped. + /// + /// # Errors + /// + /// The framer failed the association, or the secure layer did. + pub fn datagram( + &mut self, + from: SocketAddr, + bytes: &[u8], + now: Instant, + ) -> Result { + let Some(secure) = self.secure.as_mut() else { + return Ok(Yielded::default()); + }; + let up = secure.ingest(from, bytes, now) == Verdict::Raw && secure.knows(from); + if !up { + return self.settle(Yielded::default(), now); + } + let known = |a: SocketAddr| secure.knows(a) || secure.bound_path() == Some(a); + if self.book.number(from, known).is_none() { + return self.settle(Yielded::default(), now); + } + self.run( + Op::Ingest { + lane: LANE_CLEAR, + from: Some(from), + bytes, + end: false, + }, + now, + ) + } + + /// One message's bytes for `stream` (`end_of_frame` sends it; `text` = a text message). + /// + /// # Errors + /// + /// The framer refused them. + pub fn emit( + &mut self, + stream: u64, + bytes: &[u8], + end_of_frame: bool, + text: bool, + now: Instant, + ) -> Result { + self.run( + Op::Emit { + stream, + bytes, + end_of_frame, + text, + }, + now, + ) + } + + /// The clock: the secure layer's timers, and the framer's deadline when it has passed. + /// + /// # Errors + /// + /// The framer failed the association, or the secure layer did. + pub fn timeout(&mut self, now: Instant) -> Result { + if let Some(secure) = self.secure.as_mut() { + secure.handle_timeout(now); + } + if self.deadline_ns.is_some_and(|ns| instant_of(ns) <= now) { + self.deadline_ns = None; + return self.run(Op::Timer, now); + } + self.settle(Yielded::default(), now) + } + + /// When [`Self::timeout`] is next owed. + #[must_use] + pub fn deadline(&self) -> Option { + let framer = self.deadline_ns.map(instant_of); + let secure = self.secure.as_ref().and_then(Association::next_timeout); + [framer, secure].into_iter().flatten().min() + } + + /// The next datagram owed to the port, and where it goes. + pub fn poll_send(&mut self) -> Option<(SocketAddr, Vec)> { + self.send.pop_front() + } + + /// How many routes the host refused to send (to a path the secure layer does not allow, or + /// secured before there was a bound path to seal to). + #[must_use] + pub fn refused_routes(&self) -> u64 { + self.refused_routes + } + + /// The user name the far end's checks carry first, once the rendezvous settled it. + #[must_use] + pub fn user(&self) -> Option<&str> { + self.user.as_deref() + } + + /// Whether a datagram from `path` is this association's: the secure layer knows it (a verified + /// check, core's proof, an admitted candidate) or has it bound. + #[must_use] + pub fn owns(&self, path: SocketAddr) -> bool { + self.secure + .as_ref() + .is_some_and(|s| s.knows(path) || s.bound_path() == Some(path)) + } + + /// The far-end addresses this association owns ([`Self::owns`]) that it has numbered: on the + /// dialling side its admitted candidates, before any of them has sent a byte. + pub fn addresses(&self) -> impl Iterator + '_ { + self.book.by_addr.keys().copied().filter(|a| self.owns(*a)) + } + + /// How many of the far end's advertised candidates the destination judge refused (dialling). + #[must_use] + pub fn refused_candidates(&self) -> u64 { + self.refused_candidates + } + + /// Whether the keying-material item has been presented to the framer. + #[must_use] + pub fn presented(&self) -> bool { + self.presented + } + + /// The path the secure layer has bound, as the far end's address. + #[must_use] + pub fn bound(&self) -> Option { + self.secure.as_ref().and_then(Association::bound_path) + } + + /// The framer said no frame follows. + #[must_use] + pub fn ended(&self) -> bool { + self.ended + } + + fn verified(&self) -> bool { + self.secure.as_ref().is_some_and(Association::peer_verified) + } + + fn bound_path(&self) -> u32 { + self.bound().map_or(0, |a| self.book.path(a)) + } + + /// `first`, its `YIELD_MORE` re-calls, then whatever the secure layer owes the framer. + fn run(&mut self, first: Op<'_>, now: Instant) -> Result { + let mut y = Yielded::default(); + self.cross(first, &mut y, now)?; + self.settle(y, now) + } + + /// Drain the secure layer: its datagrams to the port, its plaintext to the framer, and the + /// host state (bound path, verified bit, the keying-material item) the framer has not been + /// told. + fn settle(&mut self, mut y: Yielded, now: Instant) -> Result { + loop { + let mut opened = Vec::new(); + let mut closed = false; + if let Some(secure) = self.secure.as_mut() { + while let Some(d) = secure.poll_wire() { + self.send.push_back(d); + } + while let Some(up) = secure.poll_up() { + match up { + Up::Connected => {} + Up::Keyed(k) => { + if self.presented || self.keying.is_some() { + return Err(fault("the secure layer exported keying twice")); + } + self.keying = Some(k); + } + Up::Plaintext(p) => opened.push(p), + Up::Closed => closed = true, + } + } + if let Some(f) = secure.failure() { + return Err(Refused { + outcome: Outcome::Failed, + error: format!("the secure layer failed the association: {f:?}"), + }); + } + } + if self.keying.is_some() && !self.verified() { + return Err(fault("keying material with the far end not verified")); + } + let state = (self.bound_path(), self.verified()); + let owed = state != self.told || self.keying.is_some() || !self.book.untold.is_empty(); + if opened.is_empty() && !closed && !owed { + y.wire.clear(); + return Ok(y); + } + for p in &opened { + let from = self.bound(); + self.cross( + Op::Ingest { + lane: LANE_SECURED, + from, + bytes: p, + end: false, + }, + &mut y, + now, + )?; + } + if closed || (opened.is_empty() && owed) { + self.cross( + Op::Ingest { + lane: 0, + from: None, + bytes: &[], + end: closed, + }, + &mut y, + now, + )?; + } + } + } + + /// One op and its `YIELD_MORE` re-calls, each carrying the lane, each answer judged and + /// applied. + fn cross(&mut self, first: Op<'_>, y: &mut Yielded, now: Instant) -> Result<(), Refused> { + let mut op = first; + loop { + let o = self.cross_once(op)?; + let done = self.take(&o, y, now)?; + if done { + return Ok(()); + } + op = op.again(); + } + } + + fn cross_once(&mut self, op: Op<'_>) -> Result { + if self.token == 0 && !matches!(op, Op::Begin(_)) { + return Err(fault("the association's framing is closed")); + } + // The lane's host state at this call. Every pointer below is into memory that outlives + // the crossing. + let untold: Vec<(u32, String)> = self + .book + .untold + .iter() + .filter_map(|p| Some((*p, self.book.addr(*p)?.to_string()))) + .collect(); + let paths: Vec = untold + .iter() + .map(|(p, a)| DatagramPath { + path: *p, + _reserved: 0, + addr: text(a.as_bytes()), + }) + .collect(); + let verified = self.verified(); + let keying = self + .keying + .as_ref() + .filter(|_| verified) + .map(|k| KeyingMaterial { + size: u32::try_from(std::mem::size_of::()).unwrap_or(u32::MAX), + profile: k.profile.code(), + bytes: k.material().as_ptr(), + len: k.material().len(), + }); + let (lane_code, from) = match op { + Op::Ingest { lane, from, .. } => (lane, from), + _ => (0, None), + }; + let from_text = from.map(|a| a.to_string()).unwrap_or_default(); + let bound = self.bound_path(); + let lane = DatagramLane { + size: u32::try_from(std::mem::size_of::()).unwrap_or(u32::MAX), + lane: lane_code, + path: from.map_or(0, |a| self.book.path(a)), + bound, + path_addr: text(from_text.as_bytes()), + local_addr: text(self.local.as_bytes()), + verified: u32::from(verified), + _reserved: 0, + keying: keying + .as_ref() + .map_or(std::ptr::null(), |k| k as *const KeyingMaterial), + routes: self.routes.as_mut_ptr(), + routes_cap: self.routes.len(), + paths: if paths.is_empty() { + std::ptr::null() + } else { + paths.as_ptr() + }, + paths_len: paths.len(), + }; + let mut o: FramerOut = blank_out(); + let c: Crossed = match op { + Op::Begin(opening) => { + let fields: Vec = opening + .iter() + .map(|(n, v)| Field { + name: text(n.as_bytes()), + value: text(v), + }) + .collect(); + let facts = ConnFacts { + size: u32::try_from(std::mem::size_of::()).unwrap_or(u32::MAX), + _reserved: 0, + offered_name: NO_TEXT, + agreed_protocol: NO_TEXT, + peer_subject: NO_TEXT, + peer_issuer: NO_TEXT, + peer_fingerprint: NO_TEXT, + claim: NO_TEXT, + local_certificate: text(self.cert.public_der()), + }; + let mut i: BeginIn = blank_in(); + i.side = self.side; + i.facts = &facts; + if !fields.is_empty() { + i.fields = fields.as_ptr(); + i.fields_len = fields.len(); + } + i.lane = &lane; + i.sink = self.bufs.sink(); + self.door.cross(Call::Begin(&mut i, &mut o)) + } + Op::Ingest { bytes, end, .. } => { + let mut i: IngestIn = blank_in(); + i.framing = self.token; + i.bytes = bytes.as_ptr(); + i.len = bytes.len(); + i.end = u32::from(end); + i.lane = &lane; + i.sink = self.bufs.sink(); + self.door.cross(Call::Ingest(&mut i, &mut o)) + } + Op::Emit { + stream, + bytes, + end_of_frame, + text, + } => { + let mut i: EmitIn = blank_in(); + i.framing = self.token; + i.stream = stream; + i.bytes = bytes.as_ptr(); + i.len = bytes.len(); + i.end_of_frame = u32::from(end_of_frame); + i.flags = if text { EMIT_TEXT } else { 0 }; + i.lane = &lane; + i.sink = self.bufs.sink(); + self.door.cross(Call::Emit(&mut i, &mut o)) + } + Op::Timer => { + let mut i: FramingIn = blank_in(); + i.framing = self.token; + i.lane = &lane; + i.sink = self.bufs.sink(); + self.door.cross(Call::Timer(&mut i, &mut o)) + } + }; + if let Err(f) = check_datagram(c.outcome, &o, &self.routes, self.routes.len() as u64) { + return Err(fault(format!( + "the framer's datagram answer breaks {:?} at {}", + f.rule, f.field + ))); + } + ready(c)?; + if matches!(op, Op::Begin(_)) { + self.token = o.framing; + } + // What this call carried reached the framer: the paths are told, the host state is the + // one it now holds, and the keying-material item is presented — dropped here, zeroed. + self.book.untold.clear(); + self.told = (bound, verified); + if keying.is_some() { + self.keying = None; + self.presented = true; + } + Ok(o) + } + + /// Copy out and apply what one READY answer asked; `true` = it owes nothing more. + fn take(&mut self, o: &FramerOut, y: &mut Yielded, now: Instant) -> Result { + let d = &o.datagram; + let mut asked = Asked { + request: (d.request, d.request_from, d.request_to), + ..Asked::default() + }; + if d.routes_len != 0 { + let wire = self + .bufs + .wire_written(o.yielded.wire_len) + .ok_or_else(|| fault("the framer's routes are out of bounds"))?; + for r in self.routes.iter().take(d.routes_len as usize) { + let at = usize::try_from(r.offset).map_err(|_| fault("route offset"))?; + let len = usize::try_from(r.len).map_err(|_| fault("route length"))?; + let bytes = wire + .get(at..at.saturating_add(len)) + .ok_or_else(|| fault("the framer's routes are out of bounds"))?; + asked.routes.push((r.path, r.lane, bytes.to_vec())); + } + } + if d.terms.role != HANDSHAKE_NONE { + let frame = self + .bufs + .frame_written(o.yielded.frame_len) + .ok_or_else(|| fault("the framer's terms are out of bounds"))?; + asked.terms = Some(read_terms(&d.terms, frame)?); + } + let done = self.bufs.take(o, y)?; + self.ended |= y.ended; + self.deadline_ns = y.deadline_ns; + if let Some(t) = asked.terms { + if self.secure.is_some() { + return Err(fault("the framer answered terms twice")); + } + self.user = Some(t.local.ufrag.clone()); + self.secure = Some(Association::new( + Arc::clone(&self.cert), + t.role, + &t.local, + &t.remote, + t.fingerprint, + )); + if self.side == SIDE_DIAL { + self.admit(&t.candidates, now); + } + } + for (path, lane, bytes) in asked.routes { + self.send_answered(path, lane, bytes); + } + self.request(asked.request, now); + Ok(done) + } + + /// The dialling side's candidates: each judged by the destination judge first; an admitted one + /// numbered (told to the framer on the next call) and handed to the secure layer, which probes + /// it. A refused, unparsable or still-pending one is dropped and counted. + fn admit(&mut self, candidates: &[String], now: Instant) { + let Some((judge, class)) = self.judge.clone() else { + return; + }; + for c in candidates { + let admitted = c.parse::().ok().filter(|addr| { + // A literal is judged at once; a judgement that would pend is no admission. + matches!( + judge.judge_dial(&addr.to_string(), class, Box::new(|_| {})), + Some(Ok(pinned)) if pinned == *addr + ) + }); + let Some(addr) = admitted else { + self.refused_candidates += 1; + continue; + }; + let Some(secure) = self.secure.as_mut() else { + return; + }; + if !secure.admit_candidate(addr, now) { + self.refused_candidates += 1; + continue; + } + let known = |a: SocketAddr| secure.knows(a) || secure.bound_path() == Some(a); + if self.book.number(addr, known).is_none() { + self.refused_candidates += 1; + } + } + } + + /// Send one route where the secure layer allows it, or drop it. + fn send_answered(&mut self, path: u32, lane: u32, bytes: Vec) { + let to = self.book.addr(path); + let sent = match (self.secure.as_mut(), to) { + (Some(secure), Some(to)) if lane == LANE_CLEAR => { + let ok = secure.may_send_clear(to, &bytes); + if ok { + self.send.push_back((to, bytes)); + } + ok + } + (Some(secure), Some(to)) if lane == LANE_SECURED => { + secure.bound_path() == Some(to) && secure.seal(&bytes).is_ok() + } + _ => false, + }; + if !sent { + self.refused_routes += 1; + } + } + + /// A path request, to the secure layer; a refused one leaves the bound path where it is. + fn request(&mut self, (request, from, to): (u32, u32, u32), now: Instant) { + let Some(secure) = self.secure.as_mut() else { + return; + }; + let Some(to) = self.book.addr(to) else { + return; + }; + match request { + PATH_REQUEST_BIND => { + let _ = secure.bind_path(to, now); + } + PATH_REQUEST_REBIND => { + if let Some(from) = self.book.addr(from) { + let _ = secure.rebind_path(from, to, now); + } + } + _ => {} + } + } +} + +/// The rendezvous terms a framer answered, read out of its frame (already judged by +/// `check_terms`). +fn read_terms(t: &RendezvousTerms, frame: &[u8]) -> Result { + let span = |s: busbar_contract::abi::transport::FrameSpan| -> Result<&[u8], Refused> { + let at = usize::try_from(s.offset).map_err(|_| fault("terms span"))?; + let len = usize::try_from(s.len).map_err(|_| fault("terms span"))?; + frame + .get(at..at.saturating_add(len)) + .ok_or_else(|| fault("the framer's terms are out of bounds")) + }; + let utf8 = |s| -> Result { + String::from_utf8(span(s)?.to_vec()) + .map_err(|_| fault("the framer's check credentials are not text")) + }; + let role = match t.role { + HANDSHAKE_INITIATES => Role::Client, + HANDSHAKE_ANSWERS => Role::Server, + _ => return Err(fault("the framer's terms name no handshake role")), + }; + let candidates = String::from_utf8(span(t.candidates)?.to_vec()) + .map_err(|_| fault("the far end's candidates are not text"))? + .lines() + .map(str::trim) + .filter(|l| !l.is_empty()) + .map(str::to_owned) + .collect(); + let mut fingerprint = [0_u8; FINGERPRINT_BYTES as usize]; + let stated = span(t.peer_fingerprint)? + .get(..fingerprint.len()) + .ok_or_else(|| fault("the far end's fingerprint is short"))?; + fingerprint.copy_from_slice(stated); + Ok(Terms { + role, + local: IceCredentials { + ufrag: utf8(t.local_user)?, + pwd: utf8(t.local_secret)?, + }, + remote: IceCredentials { + ufrag: utf8(t.remote_user)?, + pwd: utf8(t.remote_secret)?, + }, + fingerprint, + candidates, + }) +} + +/// THE PORT'S ASSOCIATIONS: every association one host port carries, and the demultiplexer that +/// finds the one a datagram belongs to. A connectivity check names its association by the user +/// name its first USERNAME field carries (the association's own; the association still verifies +/// the check's integrity itself); any other datagram is found by the path it came from, which an +/// association claims only once its secure layer knows it. A datagram neither finds is dropped +/// here, before any association or framer sees it. +/// +/// One port carries many ACCEPTED associations: each far end reaches it from its own address. A +/// DIALLED association takes a port of its own, because two calls to one far end would share +/// every address of the 5-tuple. +#[derive(Debug, Default)] +pub struct Associations { + framings: HashMap, + by_user: HashMap, + by_path: HashMap, + last: u64, +} + +/// Why an association was not added to a port. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum NotAdded { + /// Its rendezvous has not settled terms: nothing would find it. + NoTerms, + /// Another association on the port already answers to its user name. + UserTaken, +} + +impl Associations { + /// An empty port. + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// Carry `framing` on this port; its number. + /// + /// # Errors + /// + /// [`NotAdded`]: no terms yet, or its user name is another association's. + pub fn add(&mut self, framing: DatagramFraming) -> Result { + let user = framing.user().ok_or(NotAdded::NoTerms)?.to_owned(); + if self.by_user.contains_key(&user) { + return Err(NotAdded::UserTaken); + } + self.last += 1; + let id = self.last; + self.by_user.insert(user, id); + // A dialled association hears nothing first: its admitted candidates' answers find it by + // their path. A path another association already holds stays that association's (a + // dialled association that shares a far end with another is given a port of its own). + for addr in framing.addresses() { + self.by_path.entry(addr).or_insert(id); + } + self.framings.insert(id, framing); + Ok(id) + } + + /// Stop carrying association `id`; the framing, for its owner to finish. + pub fn remove(&mut self, id: u64) -> Option { + let f = self.framings.remove(&id)?; + self.by_user.retain(|_, v| *v != id); + self.by_path.retain(|_, v| *v != id); + Some(f) + } + + /// The association `id`. + pub fn get_mut(&mut self, id: u64) -> Option<&mut DatagramFraming> { + self.framings.get_mut(&id) + } + + /// How many associations the port carries. + #[must_use] + pub fn len(&self) -> usize { + self.framings.len() + } + + /// Whether the port carries none. + #[must_use] + pub fn is_empty(&self) -> bool { + self.framings.is_empty() + } + + /// One datagram the port read from `from`: the association it belongs to and what that + /// association answered; `None` = it belongs to none and was dropped. An association that + /// fails is removed (its framing dropped, which finishes it). + pub fn datagram( + &mut self, + from: SocketAddr, + bytes: &[u8], + now: Instant, + ) -> Option<(u64, Result)> { + let id = match check_user(bytes) { + Some(user) => self.by_user.get(user).copied(), + None => self.by_path.get(&from).copied(), + }?; + let f = self.framings.get_mut(&id)?; + let answer = f.datagram(from, bytes, now); + if answer.is_ok() && f.owns(from) { + self.by_path.insert(from, id); + } + if answer.is_err() { + self.remove(id); + } + Some((id, answer)) + } + + /// The clock, for every association whose deadline has passed; a failed one is removed. + pub fn timeout(&mut self, now: Instant) -> Vec<(u64, Result)> { + let due: Vec = self + .framings + .iter() + .filter(|(_, f)| f.deadline().is_some_and(|d| d <= now)) + .map(|(id, _)| *id) + .collect(); + let mut out = Vec::new(); + for id in due { + if let Some(f) = self.framings.get_mut(&id) { + let answer = f.timeout(now); + if answer.is_err() { + self.remove(id); + } + out.push((id, answer)); + } + } + out + } + + /// The earliest deadline of any association. + #[must_use] + pub fn deadline(&self) -> Option { + self.framings + .values() + .filter_map(DatagramFraming::deadline) + .min() + } + + /// The next datagram any association owes the port, and where it goes. + pub fn poll_send(&mut self) -> Option<(SocketAddr, Vec)> { + self.framings + .values_mut() + .find_map(DatagramFraming::poll_send) + } +} + +/// The user name a connectivity check names its receiver by: the part of its first USERNAME +/// field before the `:`. `None` for anything that is not a Binding request carrying one. +fn check_user(d: &[u8]) -> Option<&str> { + const HEADER: usize = 20; + if d.len() < HEADER || d[0..2] != [0x00, 0x01] || d[4..8] != [0x21, 0x12, 0xa4, 0x42] { + return None; + } + let mut at = HEADER; + while let Some(head) = d.get(at..at + 4) { + let kind = u16::from_be_bytes([head[0], head[1]]); + let len = usize::from(u16::from_be_bytes([head[2], head[3]])); + let value = d.get(at + 4..at + 4 + len)?; + if kind == 0x0006 { + let name = std::str::from_utf8(value).ok()?; + return name.split(':').next().filter(|u| !u.is_empty()); + } + at += 4 + len.div_ceil(4) * 4; + } + None +} + +impl Drop for DatagramFraming { + fn drop(&mut self) { + if self.token == 0 { + return; + } + let mut i: FinishIn = blank_in(); + i.framing = self.token; + i.reason = CLOSE_NORMAL; + i.sink = self.bufs.sink(); + let mut o: FramerOut = blank_out(); + let _ = self.door.cross(Call::Finish(&mut i, &mut o)); + self.token = 0; + } +} + +#[cfg(test)] +#[allow(unsafe_code)] +#[path = "tests/datagram_tests.rs"] +mod tests; diff --git a/crates/busbar-core-connector/src/dtls/mod.rs b/crates/busbar-core-connector/src/dtls/mod.rs index 454b2af351..6c8236aa96 100644 --- a/crates/busbar-core-connector/src/dtls/mod.rs +++ b/crates/busbar-core-connector/src/dtls/mod.rs @@ -34,6 +34,16 @@ //! path is proven, and refuses it outright onto a path it never heard. The DTLS session (and so //! SCTP) follows a rebind: same keys, same association, the call is not dropped. //! +//! # The dialling side: admitted candidates +//! +//! A dialling association has heard nothing yet: the far end's description advertised where it +//! listens, and the HOST judged each address with its destination guard before anything is sent +//! there. A judged address is an ADMITTED CANDIDATE ([`Association::admit_candidate`]): core probes +//! it at once, the framer's own checks may go to it, and a bind to it is accepted on the same terms +//! as a heard path — applied only once core's probe is answered from that path. A candidate the +//! guard refused is never admitted, so nothing but the bound path and admitted candidates ever +//! receives a byte. +//! //! Consent freshness (RFC 7675 §5.1): the bound path is re-probed every [`CONSENT_INTERVAL`]; with //! no proof for [`CONSENT_TIMEOUT`] the association fails and sends nothing more. //! @@ -62,7 +72,7 @@ mod ring; pub mod stun; -use std::collections::{HashMap, VecDeque}; +use std::collections::{HashMap, HashSet, VecDeque}; use std::net::SocketAddr; use std::sync::Arc; use std::time::{Duration, Instant}; @@ -71,7 +81,7 @@ use ::ring::rand::{SecureRandom as _, SystemRandom}; pub use stun::IceCredentials; use zeroize::Zeroizing; -/// How many distinct heard paths one association remembers. +/// How many distinct heard paths (and, apart, admitted candidates) one association remembers. const MAX_HEARD_PATHS: usize = 16; /// How many probes may be in flight. const MAX_PROBES: usize = 32; @@ -208,6 +218,17 @@ pub enum SrtpProfile { AeadAes256Gcm, } +impl SrtpProfile { + /// The profile's registry code (RFC 7714 §14.2), as the keying-material item names it. + #[must_use] + pub fn code(self) -> u32 { + match self { + SrtpProfile::AeadAes128Gcm => 0x0007, + SrtpProfile::AeadAes256Gcm => 0x0008, + } + } +} + /// The DTLS-SRTP exporter's output (RFC 5764 §4.2): the profile and the keying material, zeroed /// on drop. The one secret the framer holds — it protects media, like the headers a request framer /// sees. @@ -293,6 +314,10 @@ pub struct Association { dtls: Option, /// Paths a verified check came from, and when the last one did. heard: HashMap, + /// Far-end candidates the host's destination guard admitted (the dialling side). + candidates: HashSet, + /// Core's probes say ICE-CONTROLLING (the dialling side) rather than ICE-CONTROLLED. + controlling: bool, proven: HashMap, probes: HashMap<[u8; 12], (SocketAddr, Instant)>, tie_breaker: [u8; 8], @@ -339,6 +364,8 @@ impl Association { remote_fingerprint, dtls: None, heard: HashMap::new(), + candidates: HashSet::new(), + controlling: false, proven: HashMap::new(), probes: HashMap::new(), tie_breaker, @@ -379,7 +406,9 @@ impl Association { } Verdict::Raw } - Class::Media if self.heard.contains_key(&path) => Verdict::Raw, + Class::Media if self.heard.contains_key(&path) || self.bound == Some(path) => { + Verdict::Raw + } Class::Dtls => { if self.bound == Some(path) { if let Some(dtls) = self.dtls.as_mut() { @@ -389,7 +418,9 @@ impl Association { } } self.drive(); - } else if self.heard.contains_key(&path) && self.early.len() < MAX_EARLY { + } else if (self.heard.contains_key(&path) || self.candidates.contains(&path)) + && self.early.len() < MAX_EARLY + { self.early.push((path, datagram.to_vec())); } Verdict::Kept @@ -409,7 +440,7 @@ impl Association { if self.failed.is_some() { return Err(BindRefused::Failed); } - if !self.heard.contains_key(&path) { + if !self.heard.contains_key(&path) && !self.candidates.contains(&path) { return Err(BindRefused::Unchecked); } self.wanted = Some(path); @@ -441,7 +472,7 @@ impl Association { if self.bound != Some(old) { return Err(BindRefused::NotBound); } - if !self.heard.contains_key(&new) { + if !self.heard.contains_key(&new) && !self.candidates.contains(&new) { return Err(BindRefused::Unchecked); } self.wanted = Some(new); @@ -455,7 +486,8 @@ impl Association { } /// May the framer send `datagram` (clear lane) to `path`? A STUN success response — the framer - /// answering a check — to a heard or proven path; anything else only to the bound path. + /// answering a check — to a heard or proven path; a check of its own to an admitted candidate; + /// anything else only to the bound path. #[must_use] pub fn may_send_clear(&self, path: SocketAddr, datagram: &[u8]) -> bool { if self.failed.is_some() { @@ -464,9 +496,29 @@ impl Association { if stun::is_binding_success(datagram) { return self.heard.contains_key(&path) || self.proven.contains_key(&path); } + if stun::is_binding_request(datagram) && self.candidates.contains(&path) { + return true; + } self.bound == Some(path) } + /// The host's destination guard admitted `path`, a far-end candidate the description advertised + /// (the dialling side; module docs): probe it now, and let a bind to it apply once it is proven. + /// `false` = not admitted (the association failed, or it already holds as many candidates as it + /// remembers heard paths). + pub fn admit_candidate(&mut self, path: SocketAddr, now: Instant) -> bool { + if self.failed.is_some() { + return false; + } + if !self.candidates.contains(&path) && self.candidates.len() >= MAX_HEARD_PATHS { + return false; + } + self.controlling = true; + self.candidates.insert(path); + self.probe(path, now); + true + } + /// Seal `plaintext` (SCTP, for the data channels) as DTLS application data toward the bound path. /// /// # Errors @@ -564,6 +616,23 @@ impl Association { self.bound } + /// Whether a verified check came from `path` (or core's own probe proved it) and consent has + /// not aged it out, or the host admitted it as a candidate: the only paths whose datagrams the + /// framer is handed. + #[must_use] + pub fn knows(&self, path: SocketAddr) -> bool { + self.heard.contains_key(&path) + || self.proven.contains_key(&path) + || self.candidates.contains(&path) + } + + /// Whether the far end's certificate matched the fingerprint its description carried. Nothing + /// is exported, connected or opened before it does. + #[must_use] + pub fn peer_verified(&self) -> bool { + self.peer_verified && self.failed.is_none() + } + /// Record a verified check from `path`. At the cap the oldest heard path that is neither bound /// nor awaited makes room, so a flood of spoofed sources can neither grow the set nor lock a /// real new path out of it. @@ -615,8 +684,10 @@ impl Association { return; } self.probes.insert(txid, (path, now)); - self.wire - .push_back((path, stun::probe(&self.keys, txid, self.tie_breaker))); + self.wire.push_back(( + path, + stun::probe(&self.keys, txid, self.tie_breaker, self.controlling), + )); } /// A probe to `path` was answered: apply a bind waiting on it. diff --git a/crates/busbar-core-connector/src/dtls/stun.rs b/crates/busbar-core-connector/src/dtls/stun.rs index 3d067b7bb0..fb8ffdf5b6 100644 --- a/crates/busbar-core-connector/src/dtls/stun.rs +++ b/crates/busbar-core-connector/src/dtls/stun.rs @@ -29,6 +29,8 @@ const MESSAGE_INTEGRITY: u16 = 0x0008; const PRIORITY: u16 = 0x0024; /// ICE-CONTROLLED. const ICE_CONTROLLED: u16 = 0x8029; +/// ICE-CONTROLLING. +const ICE_CONTROLLING: u16 = 0x802a; /// FINGERPRINT. const FINGERPRINT: u16 = 0x8028; /// FINGERPRINT's XOR constant (RFC 8489 §14.7). @@ -164,6 +166,11 @@ pub(crate) fn verified_probe_answer(d: &[u8], keys: &Keys) -> Option<[u8; 12]> { None } +/// Is `d` a Binding request (whoever it is for)? +pub(crate) fn is_binding_request(d: &[u8]) -> bool { + d.len() >= HEADER && d[0..2] == BINDING_REQUEST && d[4..8] == MAGIC_COOKIE +} + /// Is `d` a Binding success response (whoever it answers)? pub(crate) fn is_binding_success(d: &[u8]) -> bool { d.len() >= HEADER && d[0..2] == BINDING_SUCCESS && d[4..8] == MAGIC_COOKIE @@ -181,9 +188,14 @@ fn set_len(m: &mut [u8], body: usize) { } /// A consent probe: a Binding request to the peer under the REMOTE credentials (USERNAME -/// "remote:local", MESSAGE-INTEGRITY with the remote password), as the controlled agent, with -/// FINGERPRINT. -pub(crate) fn probe(keys: &Keys, txid: [u8; 12], tie_breaker: [u8; 8]) -> Vec { +/// "remote:local", MESSAGE-INTEGRITY with the remote password), as the controlled agent (or, on +/// the dialling side, the controlling one), with FINGERPRINT. +pub(crate) fn probe( + keys: &Keys, + txid: [u8; 12], + tie_breaker: [u8; 8], + controlling: bool, +) -> Vec { let mut m = Vec::with_capacity(128); m.extend_from_slice(&BINDING_REQUEST); m.extend_from_slice(&[0, 0]); @@ -191,7 +203,12 @@ pub(crate) fn probe(keys: &Keys, txid: [u8; 12], tie_breaker: [u8; 8]) -> Vec Refused { +pub(crate) fn refused(c: Crossed) -> Refused { Refused { outcome: c.outcome, error: String::from_utf8_lossy(c.error.as_deref().unwrap_or_default()).into_owned(), @@ -374,14 +374,14 @@ impl Buffers { } /// The default buffers for a framing on `side`. - fn for_side(side: u32) -> Self { + pub(crate) fn for_side(side: u32) -> Self { Self { side, ..Self::default() } } - fn sink(&mut self) -> FramerSink { + pub(crate) fn sink(&mut self) -> FramerSink { FramerSink { wire: self.wire.as_mut_ptr(), wire_cap: self.wire.len(), @@ -396,8 +396,18 @@ impl Buffers { } } + /// The first `len` bytes the last answer wrote into the wire buffer; `None` = out of bounds. + pub(crate) fn wire_written(&self, len: u64) -> Option<&[u8]> { + self.wire.get(..usize::try_from(len).ok()?) + } + + /// The first `len` bytes the last answer wrote into the frame buffer; `None` = out of bounds. + pub(crate) fn frame_written(&self, len: u64) -> Option<&[u8]> { + self.frame.get(..usize::try_from(len).ok()?) + } + /// Take what one READY answer wrote; `false` = it owes more (`YIELD_MORE`). - fn take(&mut self, o: &FramerOut, into: &mut Yielded) -> Result { + pub(crate) fn take(&mut self, o: &FramerOut, into: &mut Yielded) -> Result { let y = &o.yielded; let bad = |what: &str| Refused { outcome: Outcome::Fault, @@ -545,7 +555,7 @@ impl std::fmt::Debug for Framing { } } -fn ready(c: Crossed) -> Result<(), Refused> { +pub(crate) fn ready(c: Crossed) -> Result<(), Refused> { if c.outcome == Outcome::Ready { Ok(()) } else { diff --git a/crates/busbar-core-connector/src/guard.rs b/crates/busbar-core-connector/src/guard.rs index c3658a1164..3a5247fe94 100644 --- a/crates/busbar-core-connector/src/guard.rs +++ b/crates/busbar-core-connector/src/guard.rs @@ -347,7 +347,7 @@ impl Metadata { let mut by_host: HashMap> = HashMap::new(); for (url, own) in &d.url_allow { // The one http(s) URL reader; a URL it cannot read names no host, so carves nothing. - if let Ok((_, host, _, _)) = busbar_kernel::net_guard::split_url(url) { + if let Ok((_, host, _, _)) = busbar_kernel_egress::trust::net::split_url(url) { by_host .entry(norm(&host)) .or_default() diff --git a/crates/busbar-core-connector/src/hostio.rs b/crates/busbar-core-connector/src/hostio.rs index 2125b2ffb2..daeb6a30d1 100644 --- a/crates/busbar-core-connector/src/hostio.rs +++ b/crates/busbar-core-connector/src/hostio.rs @@ -25,7 +25,7 @@ use std::collections::HashMap; use std::io::{self, Read, Write}; -use std::net::{Shutdown, SocketAddr, TcpListener, TcpStream}; +use std::net::{IpAddr, Shutdown, SocketAddr, TcpListener, TcpStream}; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::{Arc, Mutex, PoisonError, RwLock}; use std::task::{Context, Poll, Waker}; @@ -670,6 +670,58 @@ impl IoHost for HostIo { } } +/// THE SYSTEM NAME LOOKUP the kernel's remaining resolve-then-pin dials ask through its egress-trust +/// capability (row 130 E2: the kernel opens no socket, a resolver's included, so its lookup is the +/// connector's host I/O, the one place here that touches the OS; the root installs this behind the +/// capability at boot). Returns EVERY address the +/// name answered with, de-duplicated but otherwise untouched and unsorted: filtering or re-ordering +/// would quietly decide which address the guard gets to judge, and the guard judges all of them; a +/// name answering one address under both a v4 and a v6 query is ONE fact. +/// +/// ## Why a thread of its own +/// +/// The seam is synchronous, so this runs `getaddrinfo` and waits. Not `Handle::current().block_on` +/// and not `block_in_place`: both make assumptions about the caller (that there IS a runtime, that +/// it is multi-threaded) that a synchronous seam cannot make, and its callers reach it from +/// different postures — a foreign thread already inside `Runtime::block_on`, a boxed future on a +/// shared worker, a plain `async fn`. A thread with its own current-thread runtime has no such +/// precondition and cannot panic on the caller's behalf. (Moved verbatim from the kernel's deleted +/// `net_guard::SystemResolver`.) +#[derive(Clone, Copy, Debug, Default)] +pub struct SystemNames; + +impl busbar_kernel_egress::trust::net::Resolver for SystemNames { + fn resolve(&self, host: &str) -> Result, String> { + // Port zero: this seam answers about ADDRESSES. The port belongs to the URL and is applied + // by the caller that built the pin, so asking the resolver about one would be asking a + // second question. + let target = format!("{host}:0"); + std::thread::scope(|s| { + s.spawn(|| { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_err(|e| format!("name lookup: could not start a runtime: {e}"))?; + rt.block_on(async move { + let answered = tokio::net::lookup_host(target) + .await + .map_err(|e| e.to_string())?; + let mut out: Vec = Vec::new(); + for sa in answered { + let ip = sa.ip(); + if !out.contains(&ip) { + out.push(ip); + } + } + Ok(out) + }) + }) + .join() + .map_err(|_| "name lookup: the worker thread panicked".to_string())? + }) + } +} + #[cfg(test)] #[path = "tests/hostio_tests.rs"] mod tests; diff --git a/crates/busbar-core-connector/src/lib.rs b/crates/busbar-core-connector/src/lib.rs index 34d46a8334..1f53eb31ff 100644 --- a/crates/busbar-core-connector/src/lib.rs +++ b/crates/busbar-core-connector/src/lib.rs @@ -19,6 +19,9 @@ //! * [`dtls`] is its datagram sibling — the DTLS engine a media association runs on (the RFC 7983 //! demux, the ICE-gated bind, the SRTP exporter), on ring like [`tls`]. //! * [`udp`] is the host's datagram socket: one bound port, a peer per datagram. +//! * [`datagram`] drives one association's datagram framer over the port and the dtls engine: the +//! datagram lane on every call, the keying-material item presented once, routes and path +//! requests only where the secure layer allows them. //! //! * [`hostio`] is THE HOST'S I/O (`io.*`): the one place that holds an OS handle (a stream, a //! listener, a program's pipes) and moves bytes over it, admitting only what the destination @@ -47,6 +50,7 @@ pub mod carrier; pub mod compose; +pub mod datagram; pub mod dtls; pub mod endpoint; pub mod framed_stream; diff --git a/crates/busbar-core-connector/src/process.rs b/crates/busbar-core-connector/src/process.rs index 9c5d07511e..a9d00aa41e 100644 --- a/crates/busbar-core-connector/src/process.rs +++ b/crates/busbar-core-connector/src/process.rs @@ -134,9 +134,11 @@ fn split(dest: &str) -> Result<(String, u16, bool), u64> { if let Some((scheme, rest)) = dest.split_once("://") { let lowered = format!("{}://{rest}", scheme.to_ascii_lowercase()); // The one http(s) URL reader (scheme allowlist, userinfo refused, the scheme's port). - return match busbar_kernel::net_guard::split_url(&lowered) { + return match busbar_kernel_egress::trust::net::split_url(&lowered) { Ok((https, host, port, _)) => Ok((host, port, https)), - Err(busbar_kernel::net_guard::AddressRefusal::Scheme { .. }) => Err(DEST_SCHEME), + Err(busbar_kernel_egress::trust::net::AddressRefusal::Scheme { .. }) => { + Err(DEST_SCHEME) + } Err(_) => Err(DEST_NO_HOST), }; } diff --git a/crates/busbar-core-connector/src/tests/datagram_tests.rs b/crates/busbar-core-connector/src/tests/datagram_tests.rs new file mode 100644 index 0000000000..8c4ad7fbb6 --- /dev/null +++ b/crates/busbar-core-connector/src/tests/datagram_tests.rs @@ -0,0 +1,941 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (C) 2026 Busbar Inc and contributors + +//! THE DATAGRAM DRIVER, end to end over a real handshake: a scripted datagram framer behind the +//! [`FramerDoor`] seam (an ICE-lite agent: it answers checks, asks to bind the path a check came +//! from, and hands secured plaintext up on stream `1`), driven by [`DatagramFraming`] over the +//! host's dtls engine, against a far end that is a second dtls engine plus the test acting as its +//! full ICE agent. +//! +//! What is proven, each with its RED arm: +//! * the keying-material item reaches the framer exactly ONCE, on a call whose lane says the far end +//! is verified, and its bytes are what the far end's own exporter produced; +//! * a datagram from a source that sent no verified check never reaches the framer, and no path +//! number is told for it; +//! * a rebind to a path that never answered core's probe is not applied, and a clear route to a +//! heard but unbound path that is not a check's answer is dropped and counted; +//! * a framer answer that breaks the datagram lane's rules is a FAULT before any byte is used; +//! * DIALLING: every advertised candidate meets the destination judge before any byte goes there; +//! a refused one is never told to the framer and never sent a byte, and an admitted one binds +//! only once core's probe is answered from it (RED arms: unanswered, or answered from another +//! source, it never binds). + +use std::collections::HashMap; +use std::net::SocketAddr; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; + +use busbar_contract::abi::mechanism::call::Outcome; +use busbar_contract::abi::transport::{ + DatagramLane, DatagramRoute, FramePiece, FrameSpan, FramerOut, FramerSink, RendezvousTerms, + HANDSHAKE_ANSWERS, HANDSHAKE_INITIATES, LANE_CLEAR, LANE_RENDEZVOUS, LANE_SECURED, + PATH_REQUEST_BIND, PATH_REQUEST_REBIND, PIECE_END_OF_FRAME, ROLE_FRAMER, +}; + +use super::DatagramFraming; +use crate::dtls::{Association, IceCredentials, Role, SessionCert, SrtpKeying, Up, Verdict}; +use crate::framer::{Call, Crossed, DoorFacts, FramerDoor}; +use crate::{DialJudge, Judged}; + +const HOST_UFRAG: &str = "hostfrag"; +const HOST_PWD: &str = "host-password-0123456789"; +const FAR_UFRAG: &str = "farfrag"; +const FAR_PWD: &str = "far-password-0123456789"; + +fn p(s: &str) -> SocketAddr { + s.parse().expect("addr") +} + +fn creds(ufrag: &str, pwd: &str) -> IceCredentials { + IceCredentials { + ufrag: ufrag.into(), + pwd: pwd.into(), + } +} + +// ── STUN, as a full ICE agent writes it ───────────────────────────────────────────────────────── + +/// A Binding request to the owner of `to`: USERNAME "to.ufrag:from_ufrag", MESSAGE-INTEGRITY under +/// `key` (the receiver's password when honest). +fn check(to_ufrag: &str, from_ufrag: &str, key: &str, txid: u8) -> Vec { + let username = format!("{to_ufrag}:{from_ufrag}").into_bytes(); + let padded = username.len().div_ceil(4) * 4; + let mut m = vec![0x00, 0x01, 0, 0, 0x21, 0x12, 0xa4, 0x42]; + m.extend_from_slice(&[txid; 12]); + m.extend_from_slice(&0x0006_u16.to_be_bytes()); + m.extend_from_slice(&u16::try_from(username.len()).unwrap().to_be_bytes()); + m.extend_from_slice(&username); + m.resize(20 + 4 + padded, 0); + let covered_len = u16::try_from(m.len() - 20 + 24).unwrap(); + m[2..4].copy_from_slice(&covered_len.to_be_bytes()); + sign(m, key) +} + +/// The success response to the request `req`: same transaction id, MESSAGE-INTEGRITY under `pwd` +/// (the answering agent's own password). +fn answer(req: &[u8], pwd: &str) -> Vec { + let mut m = vec![0x01, 0x01, 0, 0, 0x21, 0x12, 0xa4, 0x42]; + m.extend_from_slice(&req[8..20]); + let covered_len = u16::try_from(m.len() - 20 + 24).unwrap(); + m[2..4].copy_from_slice(&covered_len.to_be_bytes()); + sign(m, pwd) +} + +fn sign(mut m: Vec, key: &str) -> Vec { + let key = ::ring::hmac::Key::new(::ring::hmac::HMAC_SHA1_FOR_LEGACY_USE_ONLY, key.as_bytes()); + let tag = ::ring::hmac::sign(&key, &m); + m.extend_from_slice(&0x0008_u16.to_be_bytes()); + m.extend_from_slice(&20_u16.to_be_bytes()); + m.extend_from_slice(tag.as_ref()); + m +} + +fn is_request(d: &[u8]) -> bool { + d.len() >= 20 && d[0..2] == [0x00, 0x01] +} + +// ── the scripted framer ───────────────────────────────────────────────────────────────────────── + +/// One call the framer saw: the lane's host state as it arrived. +#[derive(Debug, Clone, Default)] +struct Seen { + lane: u32, + path: u32, + bound: u32, + verified: bool, + keying: Option<(u32, Vec)>, + told: Vec<(u32, String)>, +} + +#[derive(Debug, Clone, Copy, Default)] +struct Knobs { + /// Answer the rendezvous with a zero-length route. + bad_route: bool, + /// On a check from a path other than the bound one: ask to move there, and route it a clear + /// datagram that is not a check's answer. + rebind_new: bool, + /// A dialling framing: it offers at begin, settles terms that initiate and carry the far end's + /// candidates, and checks every path it is told of, asking to bind the first. + dial: bool, +} + +#[derive(Default)] +struct St { + seen: Vec, + addrs: HashMap, + remote: Option<(String, String, Vec)>, + secured: Vec>, + routes: Vec<(u32, u32, Vec)>, + pieces: Vec<(u64, Vec)>, + terms: bool, + request: (u32, u32, u32), + candidates: String, + txid: u8, + /// This framing's own check credentials. + local: (&'static str, &'static str), +} + +struct Scripted { + facts: DoorFacts, + knobs: Knobs, + st: Mutex, +} + +fn scripted(knobs: Knobs) -> Arc { + scripted_as(knobs, (HOST_UFRAG, HOST_PWD)) +} + +fn scripted_as(knobs: Knobs, local: (&'static str, &'static str)) -> Arc { + Arc::new(Scripted { + facts: DoorFacts { + name: "scripted".into(), + claims: vec!["scripted"], + role: ROLE_FRAMER, + composes_over: Vec::new(), + ported: false, + status_rows: Vec::new(), + duplex: Vec::new(), + }, + knobs, + st: Mutex::new(St { + local, + ..St::default() + }), + }) +} + +fn raw<'a>(ptr: *const u8, len: usize) -> &'a [u8] { + if ptr.is_null() || len == 0 { + return &[]; + } + // SAFETY: host-lent bytes, valid for the call. + unsafe { std::slice::from_raw_parts(ptr, len) } +} + +/// Read the lane: what arrived on it and the host's state. +fn read_lane(st: &mut St, lane: *const DatagramLane) -> Option<&'static DatagramLane> { + if lane.is_null() { + return None; + } + // SAFETY: host-lent lane, valid for the call; the test reads it only during the call. + let l: &'static DatagramLane = unsafe { &*lane }; + let mut seen = Seen { + lane: l.lane, + path: l.path, + bound: l.bound, + verified: l.verified == 1, + ..Seen::default() + }; + if !l.keying.is_null() { + // SAFETY: as above. + let k = unsafe { &*l.keying }; + seen.keying = Some((k.profile, raw(k.bytes, k.len).to_vec())); + } + if !l.paths.is_null() { + // SAFETY: as above, `paths_len` of them. + let paths = unsafe { std::slice::from_raw_parts(l.paths, l.paths_len) }; + for path in paths { + let addr = String::from_utf8_lossy(raw(path.addr.ptr, path.addr.len)).into_owned(); + st.addrs.insert(path.path, addr.clone()); + seen.told.push((path.path, addr)); + } + } + st.seen.push(seen); + Some(l) +} + +/// Write what the framer owes into the sink and the lane's routes. +fn write_answer(st: &mut St, sink: &FramerSink, lane: Option<&DatagramLane>, o: &mut FramerOut) { + let mut wire_at = 0_usize; + let mut frame_at = 0_usize; + let mut frame = |b: &[u8]| -> FrameSpan { + assert!(frame_at + b.len() <= sink.frame_cap); + // SAFETY: the host's frame buffer, bounded above. + unsafe { std::ptr::copy_nonoverlapping(b.as_ptr(), sink.frame.add(frame_at), b.len()) }; + let s = FrameSpan { + offset: frame_at as u64, + len: b.len() as u64, + }; + frame_at += b.len(); + s + }; + if let Some(lane) = lane { + let mut n = 0_usize; + for (path, l, bytes) in st.routes.drain(..) { + assert!(n < lane.routes_cap && wire_at + bytes.len() <= sink.wire_cap); + // SAFETY: the host's wire buffer and route buffer, bounded above. + unsafe { + std::ptr::copy_nonoverlapping(bytes.as_ptr(), sink.wire.add(wire_at), bytes.len()); + lane.routes.add(n).write(DatagramRoute { + offset: wire_at as u64, + len: bytes.len() as u64, + path, + lane: l, + }); + } + wire_at += bytes.len(); + n += 1; + } + o.datagram.routes_len = n as u32; + ( + o.datagram.request, + o.datagram.request_from, + o.datagram.request_to, + ) = std::mem::take(&mut st.request); + } + if std::mem::take(&mut st.terms) { + let (ru, rp, fp) = st.remote.clone().expect("terms after a rendezvous"); + let (lu, lp) = st.local; + let candidates = std::mem::take(&mut st.candidates); + o.datagram.terms = RendezvousTerms { + role: if candidates.is_empty() { + HANDSHAKE_ANSWERS + } else { + HANDSHAKE_INITIATES + }, + _reserved: 0, + local_user: frame(lu.as_bytes()), + local_secret: frame(lp.as_bytes()), + remote_user: frame(ru.as_bytes()), + remote_secret: frame(rp.as_bytes()), + peer_fingerprint: frame(&fp), + candidates: if candidates.is_empty() { + FrameSpan::default() + } else { + frame(candidates.as_bytes()) + }, + }; + } + let mut n = 0_usize; + for (stream, bytes) in st.pieces.drain(..) { + let s = frame(&bytes); + assert!(n < sink.pieces_cap); + // SAFETY: the host's piece buffer, bounded above. + unsafe { + sink.pieces.add(n).write(FramePiece { + stream, + offset: s.offset, + len: s.len, + code: 0, + status_class: 0, + _reserved: 0, + flags: PIECE_END_OF_FRAME, + retry_after_secs: 0, + }); + } + n += 1; + } + o.yielded.wire_len = wire_at as u64; + o.yielded.frame_len = frame_at as u64; + o.yielded.pieces_len = n as u32; + o.yielded.flags = 0; +} + +fn unhex(s: &str) -> Vec { + (0..s.len() / 2) + .map(|i| u8::from_str_radix(&s[i * 2..i * 2 + 2], 16).unwrap()) + .collect() +} + +impl Scripted { + /// Dialling: a check to every path this call told, and a bind request for the first while + /// nothing is bound. + fn check_told(&self, st: &mut St, lane: &DatagramLane) { + if !self.knobs.dial { + return; + } + let told = st.seen.last().map(|s| s.told.clone()).unwrap_or_default(); + for (path, _) in told { + st.txid = st.txid.wrapping_add(1); + let c = check(FAR_UFRAG, HOST_UFRAG, FAR_PWD, 0x40 | st.txid); + st.routes.push((path, LANE_CLEAR, c)); + if lane.bound == 0 && st.request.0 == 0 { + st.request = (PATH_REQUEST_BIND, 0, path); + } + } + } +} + +impl FramerDoor for Scripted { + fn facts(&self) -> &DoorFacts { + &self.facts + } + + fn cross(&self, call: Call<'_>) -> Crossed { + let ok = Crossed { + outcome: Outcome::Ready, + error: None, + }; + let mut st = self.st.lock().unwrap(); + match call { + Call::Begin(i, o) => { + let lane = read_lane(&mut st, i.lane); + o.framing = 7; + if self.knobs.dial { + st.pieces.push((0, b"offer".to_vec())); + } + write_answer(&mut st, &i.sink, lane, o); + } + Call::Ingest(i, o) => { + let lane = read_lane(&mut st, i.lane).expect("a datagram framer reads its lane"); + let bytes = raw(i.bytes, i.len).to_vec(); + match lane.lane { + LANE_RENDEZVOUS => { + // "ufrag pwd fingerprint-hex": the far end's terms. + let text = String::from_utf8(bytes).unwrap(); + let mut w = text.split(' '); + let (u, pw, fp) = (w.next().unwrap(), w.next().unwrap(), w.next().unwrap()); + st.remote = Some((u.into(), pw.into(), unhex(fp))); + if let Some(c) = w.next() { + st.candidates = c.split(',').map(|a| format!("{a}\n")).collect(); + } + st.terms = true; + st.pieces.push((0, b"description".to_vec())); + if self.knobs.bad_route { + st.routes.push((1, LANE_CLEAR, Vec::new())); + } + } + LANE_CLEAR if is_request(&bytes) => { + let pwd = st.local.1; + st.routes.push((lane.path, LANE_CLEAR, answer(&bytes, pwd))); + if lane.bound == 0 { + st.request = (PATH_REQUEST_BIND, 0, lane.path); + } else if self.knobs.rebind_new && lane.path != lane.bound { + st.request = (PATH_REQUEST_REBIND, lane.bound, lane.path); + st.routes.push((lane.path, LANE_CLEAR, vec![0x80, 0, 0, 1])); + } + } + LANE_SECURED => { + st.secured.push(bytes.clone()); + st.pieces.push((1, bytes)); + } + _ => {} + } + self.check_told(&mut st, lane); + write_answer(&mut st, &i.sink, Some(lane), o); + } + Call::Emit(i, o) => { + let lane = read_lane(&mut st, i.lane).expect("a datagram framer reads its lane"); + let bytes = raw(i.bytes, i.len).to_vec(); + if !bytes.is_empty() { + st.routes.push((lane.bound.max(1), LANE_SECURED, bytes)); + } + write_answer(&mut st, &i.sink, Some(lane), o); + } + Call::Timer(i, o) => { + let lane = read_lane(&mut st, i.lane); + if let Some(l) = lane { + self.check_told(&mut st, l); + } + write_answer(&mut st, &i.sink, lane, o); + } + Call::Finish(..) => {} + _ => { + return Crossed { + outcome: Outcome::Refused, + error: None, + } + } + } + ok + } +} + +// ── the far end ───────────────────────────────────────────────────────────────────────────────── + +struct Far { + a: Association, + addr: SocketAddr, + ups: Vec, +} + +struct Rig { + door: Arc, + host: DatagramFraming, + host_addr: SocketAddr, + far: Far, + now: Instant, + /// Pieces the host handed up, in order. + got: Vec<(u64, Vec)>, + /// Datagrams the host sent anywhere but the far end. + elsewhere: Vec<(SocketAddr, Vec)>, + /// The far end's ICE agent answers the requests it is sent. + answers: bool, + /// Requests the far end was sent and did not answer. + unanswered: Vec>, + /// Every destination the judge was asked about. + judged: Arc>>, +} + +const METADATA: &str = "169.254.169.254:3478"; + +/// The destination judge: the link-local metadata range refused, any other literal admitted as +/// itself. +fn judge(asked: Arc>>) -> Arc { + Arc::new(move |dest: &str, _class: u32, _done: Judged| { + asked.lock().unwrap().push(dest.to_owned()); + let addr: SocketAddr = dest.parse().ok()?; + match addr.ip() { + std::net::IpAddr::V4(v4) if v4.is_link_local() => Some(Err(7)), + _ => Some(Ok(addr)), + } + }) +} + +fn hex(b: &[u8]) -> String { + b.iter().map(|x| format!("{x:02x}")).collect() +} + +fn rig(knobs: Knobs) -> Rig { + let door = scripted(knobs); + let host_cert = Arc::new(SessionCert::mint().unwrap()); + let far_cert = Arc::new(SessionCert::mint().unwrap()); + let host_addr = p("198.51.100.1:3478"); + let judged = Arc::new(Mutex::new(Vec::new())); + let host = if knobs.dial { + let opening = [("media".to_owned(), b"audio".to_vec())]; + let (host, y) = DatagramFraming::dial( + door.clone(), + Arc::clone(&host_cert), + host_addr, + &opening, + judge(Arc::clone(&judged)), + 0, + ) + .expect("the framing begins"); + assert_eq!(y.pieces.len(), 1, "the dialling framing offers at once"); + host + } else { + DatagramFraming::accept(door.clone(), Arc::clone(&host_cert), host_addr) + .expect("the framing begins") + .0 + }; + let far = Far { + a: Association::new( + Arc::clone(&far_cert), + if knobs.dial { + Role::Server + } else { + Role::Client + }, + &creds(FAR_UFRAG, FAR_PWD), + &creds(HOST_UFRAG, HOST_PWD), + host_cert.fingerprint(), + ), + addr: p("203.0.113.9:50000"), + ups: Vec::new(), + }; + let mut r = Rig { + door, + host, + host_addr, + far, + now: Instant::now(), + got: Vec::new(), + elsewhere: Vec::new(), + answers: true, + unanswered: Vec::new(), + judged, + }; + let mut description = format!("{FAR_UFRAG} {FAR_PWD} {}", hex(&far_cert.fingerprint())); + if knobs.dial { + description.push_str(&format!(" {},{METADATA},not-an-address", r.far.addr)); + } + let y = r + .host + .rendezvous(description.as_bytes(), r.now) + .expect("the rendezvous settles terms"); + r.got + .extend(y.pieces.into_iter().map(|g| (g.stream, g.bytes))); + r +} + +impl Rig { + fn deliver(&mut self, from: SocketAddr, d: &[u8]) { + let y = self + .host + .datagram(from, d, self.now) + .expect("the host takes it"); + self.got + .extend(y.pieces.into_iter().map(|g| (g.stream, g.bytes))); + } + + /// The far end's first check, then `rounds` of the clock with every datagram delivered. + fn pump(&mut self, rounds: usize) { + for _ in 0..rounds { + self.now += Duration::from_millis(10); + self.far.a.handle_timeout(self.now); + let y = self.host.timeout(self.now).expect("the host's clock"); + self.got + .extend(y.pieces.into_iter().map(|g| (g.stream, g.bytes))); + while let Some((to, d)) = self.host.poll_send() { + if to != self.far.addr { + self.elsewhere.push((to, d)); + continue; + } + let v = self.far.a.ingest(self.host_addr, &d, self.now); + if v == Verdict::Raw && is_request(&d) && !self.answers { + self.unanswered.push(d); + } else if v == Verdict::Raw && is_request(&d) { + // The far end's ICE agent answers the host's probe under its own password. + let a = answer(&d, FAR_PWD); + let from = self.far.addr; + self.deliver(from, &a); + } + } + let _ = self.far.a.bind_path(self.host_addr, self.now); + while let Some((to, d)) = self.far.a.poll_wire() { + assert_eq!(to, self.host_addr); + let from = self.far.addr; + self.deliver(from, &d); + } + while let Some(u) = self.far.a.poll_up() { + self.far.ups.push(u); + } + } + } + + fn first_check(&mut self) { + let c = check(HOST_UFRAG, FAR_UFRAG, HOST_PWD, 1); + let from = self.far.addr; + self.deliver(from, &c); + } + + fn seen(&self) -> Vec { + self.door.st.lock().unwrap().seen.clone() + } + + fn far_keying(&self) -> &SrtpKeying { + self.far + .ups + .iter() + .find_map(|u| match u { + Up::Keyed(k) => Some(k), + _ => None, + }) + .expect("the far end exported keying material") + } +} + +#[test] +fn the_keying_material_item_is_presented_once_verified_and_equal_to_the_far_ends_export() { + let mut r = rig(Knobs::default()); + assert_eq!(r.got, vec![(0, b"description".to_vec())]); + r.first_check(); + r.pump(200); + assert!(r.host.presented(), "the item was presented"); + assert_eq!( + r.host.bound(), + Some(r.far.addr), + "the far end's path is bound" + ); + + let seen = r.seen(); + let keyed: Vec<&Seen> = seen.iter().filter(|s| s.keying.is_some()).collect(); + // RED arm: a host that presents the item on every call, or never, fails here. + assert_eq!(keyed.len(), 1, "the item crosses exactly once: {seen:?}"); + let (profile, bytes) = keyed[0].keying.clone().unwrap(); + assert!( + keyed[0].verified, + "the item crosses only with the far end verified" + ); + let far = r.far_keying(); + assert_eq!( + profile, + far.profile.code(), + "the profile is the negotiated one" + ); + assert!(profile == 0x0007 || profile == 0x0008, "AEAD-AES-GCM only"); + assert_eq!( + bytes, + far.material(), + "the item is the exporter's output, both ends" + ); + // No call before the far end was verified saw the verified bit. + let first_verified = seen.iter().position(|s| s.verified).expect("verified once"); + assert!(seen[..first_verified].iter().all(|s| s.keying.is_none())); + + // Plaintext both ways over the bound path, sealed and opened by the host's secure layer. + r.far.a.seal(b"ping").unwrap(); + r.pump(5); + assert!(r.got.contains(&(1, b"ping".to_vec())), "{:?}", r.got); + r.host.emit(1, b"pong", true, false, r.now).unwrap(); + r.pump(5); + assert!(r + .far + .ups + .iter() + .any(|u| matches!(u, Up::Plaintext(p) if p == b"pong"))); + assert_eq!(r.host.refused_routes(), 0); + assert!(r.elsewhere.is_empty()); +} + +#[test] +fn a_source_that_sent_no_verified_check_never_reaches_the_framer() { + let mut r = rig(Knobs::default()); + let spoof = p("192.0.2.66:4444"); + let before = r.seen().len(); + // A check under the wrong password, and media, from a source the far end never was. + r.deliver( + spoof, + &check(HOST_UFRAG, FAR_UFRAG, "wrong-password-012345", 2), + ); + r.deliver(spoof, &[0x80, 0, 0, 1, 2, 3]); + let seen = r.seen(); + assert_eq!(seen.len(), before, "nothing crossed for the spoofed source"); + assert!(seen + .iter() + .all(|s| s.told.iter().all(|(_, a)| a != &spoof.to_string()))); + // RED arm: the honest check does cross, with its path told. + r.first_check(); + let seen = r.seen(); + assert!(seen.len() > before); + assert!(seen + .iter() + .any(|s| s.told.contains(&(1, r.far.addr.to_string())))); + // The check itself crossed on the clear lane, carrying the path number it was told under. + assert!(seen.iter().any(|s| s.lane == LANE_CLEAR && s.path == 1)); +} + +#[test] +fn a_rebind_to_a_path_that_never_answered_the_probe_is_not_applied() { + let mut r = rig(Knobs { + rebind_new: true, + ..Knobs::default() + }); + r.first_check(); + r.pump(200); + assert_eq!(r.host.bound(), Some(r.far.addr)); + // A verified check from a new source that never answers core's probe (the password leaked; the + // address is not the far end's). + let spoof = p("192.0.2.77:5555"); + r.deliver(spoof, &check(HOST_UFRAG, FAR_UFRAG, HOST_PWD, 3)); + r.pump(100); + assert_eq!( + r.host.bound(), + Some(r.far.addr), + "the bound path did not move" + ); + let last = r.seen().last().cloned().unwrap(); + assert_eq!(last.bound, 1, "the framer is still told the first path"); + // Core probed the new source (the round trip that would prove it), sent it the check's answer, + // and nothing else: the clear datagram that was no answer was dropped and counted. + assert!(r + .elsewhere + .iter() + .all(|(to, d)| *to == spoof && d.len() >= 20 && (d[0..2] == [0, 1] || d[0..2] == [1, 1]))); + assert!(r.elsewhere.iter().any(|(_, d)| d[0..2] == [1, 1])); + assert_eq!(r.host.refused_routes(), 1); + // RED arm: once the new path answers the probe, the move applies. + let probe = r + .elsewhere + .iter() + .rev() + .find(|(_, d)| d[0..2] == [0, 1]) + .map(|(_, d)| d.clone()) + .expect("core probed the new path"); + r.deliver(spoof, &answer(&probe, FAR_PWD)); + assert_eq!(r.host.bound(), Some(spoof), "a proven path is bound"); +} + +#[test] +fn a_framer_answer_that_breaks_the_lane_is_a_fault_before_any_byte_is_used() { + let door = scripted(Knobs { + bad_route: true, + ..Knobs::default() + }); + let cert = Arc::new(SessionCert::mint().unwrap()); + let (mut host, _) = + DatagramFraming::accept(door, cert, p("198.51.100.1:3478")).expect("begins"); + let e = host + .rendezvous(b"farfrag far-password-0123456789 00", Instant::now()) + .expect_err("a zero-length route is refused"); + assert_eq!(e.outcome, Outcome::Fault, "{e}"); + assert!(host.poll_send().is_none()); +} + +#[test] +fn nothing_on_the_port_is_the_associations_before_the_rendezvous() { + let door = scripted(Knobs::default()); + let cert = Arc::new(SessionCert::mint().unwrap()); + let (mut host, _) = + DatagramFraming::accept(door.clone(), cert, p("198.51.100.1:3478")).expect("begins"); + let before = door.st.lock().unwrap().seen.len(); + let y = host + .datagram( + p("203.0.113.9:50000"), + &check(HOST_UFRAG, FAR_UFRAG, HOST_PWD, 4), + Instant::now(), + ) + .unwrap(); + assert!(y.pieces.is_empty()); + assert_eq!(door.st.lock().unwrap().seen.len(), before); + // The begin told the framer the host port, and no secret. + let seen = door.st.lock().unwrap().seen.clone(); + assert!(seen.iter().all(|s| s.keying.is_none() && !s.verified)); +} + +fn dialling() -> Knobs { + Knobs { + dial: true, + ..Knobs::default() + } +} + +#[test] +fn dialling_every_candidate_meets_the_judge_and_a_refused_one_is_never_sent_a_byte() { + let mut r = rig(dialling()); + r.pump(200); + // Every advertised literal was asked about; the unparsable one never reached the judge. + let judged = r.judged.lock().unwrap().clone(); + assert!(judged.contains(&r.far.addr.to_string()), "{judged:?}"); + assert!(judged.contains(&METADATA.to_owned()), "{judged:?}"); + assert_eq!( + r.host.refused_candidates(), + 2, + "the metadata address and the non-address" + ); + // RED: the refused candidate got no byte and was never told to the framer. + assert!( + r.elsewhere.is_empty(), + "nothing left for any other address: {:?}", + r.elsewhere + ); + assert!(r + .seen() + .iter() + .all(|s| s.told.iter().all(|(_, a)| a != METADATA))); + // The admitted one binds once proven, the handshake runs and the item crosses once, verified. + assert_eq!(r.host.bound(), Some(r.far.addr)); + assert!(r.host.presented()); + let keyed: Vec = r + .seen() + .into_iter() + .filter(|s| s.keying.is_some()) + .collect(); + assert_eq!(keyed.len(), 1); + assert!(keyed[0].verified); + assert_eq!( + keyed[0].keying.as_ref().unwrap().1, + r.far_keying().material() + ); + r.host.emit(1, b"hello", true, false, r.now).unwrap(); + r.pump(5); + assert!(r + .far + .ups + .iter() + .any(|u| matches!(u, Up::Plaintext(p) if p == b"hello"))); +} + +#[test] +fn dialling_an_admitted_candidate_never_binds_unverified() { + let mut r = rig(dialling()); + r.answers = false; + r.pump(200); + // The framer asked to bind it and the far end saw core's probes, but none was answered. + assert!(!r.unanswered.is_empty(), "core probed the candidate"); + assert_eq!(r.host.bound(), None, "an unproven candidate is not bound"); + assert!(!r.host.presented()); + // Answers to every request it was sent, from ANOTHER source, prove nothing. + let unanswered = std::mem::take(&mut r.unanswered); + for d in &unanswered { + r.deliver(p("192.0.2.88:6666"), &answer(d, FAR_PWD)); + } + assert_eq!(r.host.bound(), None, "an answer from elsewhere is no proof"); + // RED arm: answered from the candidate itself, the bind applies. + let from = r.far.addr; + for d in &unanswered { + r.deliver(from, &answer(d, FAR_PWD)); + } + r.answers = true; + r.pump(200); + assert_eq!(r.host.bound(), Some(r.far.addr)); + assert!(r.host.presented()); +} + +// ── the port's demultiplexer ──────────────────────────────────────────────────────────────────── + +const B_UFRAG: &str = "bfrag"; +const B_PWD: &str = "b-password-0123456789"; + +/// An accepted association on `door`, its rendezvous settled. +fn settled(door: Arc) -> DatagramFraming { + let cert = Arc::new(SessionCert::mint().unwrap()); + let (mut f, _) = DatagramFraming::accept(door, cert, p("198.51.100.1:3478")).unwrap(); + let fp = hex(&SessionCert::mint().unwrap().fingerprint()); + f.rendezvous( + format!("{FAR_UFRAG} {FAR_PWD} {fp}").as_bytes(), + Instant::now(), + ) + .unwrap(); + f +} + +fn lanes(door: &Scripted) -> Vec { + door.st + .lock() + .unwrap() + .seen + .iter() + .map(|s| s.lane) + .collect() +} + +#[test] +fn one_port_finds_each_datagram_its_association_by_user_name_then_by_path() { + let (da, db) = ( + scripted(Knobs::default()), + scripted_as(Knobs::default(), (B_UFRAG, B_PWD)), + ); + let mut port = super::Associations::new(); + let a = port.add(settled(da.clone())).unwrap(); + let b = port.add(settled(db.clone())).unwrap(); + assert_ne!(a, b); + let (pa, pb, stranger) = ( + p("203.0.113.1:1000"), + p("203.0.113.2:2000"), + p("192.0.2.3:3000"), + ); + let now = Instant::now(); + let (before_a, before_b) = (lanes(&da).len(), lanes(&db).len()); + + // Each check finds the association its user name names. + let (id, y) = port + .datagram(pa, &check(HOST_UFRAG, FAR_UFRAG, HOST_PWD, 1), now) + .unwrap(); + assert_eq!(id, a); + y.unwrap(); + let (id, y) = port + .datagram(pb, &check(B_UFRAG, FAR_UFRAG, B_PWD, 2), now) + .unwrap(); + assert_eq!(id, b); + y.unwrap(); + assert_eq!( + lanes(&da)[before_a..], + [LANE_CLEAR], + "a saw its own check only" + ); + assert_eq!( + lanes(&db)[before_b..], + [LANE_CLEAR], + "b saw its own check only" + ); + + // Anything else finds the association by the path a verified check claimed. + let (id, _) = port.datagram(pb, &[0x80, 0x6f, 0, 1], now).unwrap(); + assert_eq!(id, b); + // RED: a user name nobody holds, and a path nobody claimed, reach no association. + assert!(port + .datagram(stranger, &check("nobody", FAR_UFRAG, HOST_PWD, 3), now) + .is_none()); + assert!(port.datagram(stranger, &[0x80, 0x6f, 0, 2], now).is_none()); + // A check that names a but fails its integrity claims no path for a. + let (id, _) = port + .datagram( + stranger, + &check(HOST_UFRAG, FAR_UFRAG, "wrong-password-012345", 4), + now, + ) + .unwrap(); + assert_eq!(id, a); + assert!(port.datagram(stranger, &[0x80, 0x6f, 0, 3], now).is_none()); + + // A user name is one association's; one with no terms is refused. + assert_eq!( + port.add(settled(scripted(Knobs::default()))), + Err(super::NotAdded::UserTaken) + ); + let unsettled = DatagramFraming::accept( + scripted(Knobs::default()), + Arc::new(SessionCert::mint().unwrap()), + p("198.51.100.1:3478"), + ) + .unwrap() + .0; + assert_eq!(port.add(unsettled), Err(super::NotAdded::NoTerms)); + // Removed, its user name and paths go with it. + assert!(port.remove(b).is_some()); + assert!(port.datagram(pb, &[0x80, 0x6f, 0, 4], now).is_none()); + assert_eq!(port.len(), 1); +} + +#[test] +fn a_dialled_association_is_found_by_its_admitted_candidates_before_they_speak() { + let mut r = rig(dialling()); + let mut port = super::Associations::new(); + let host = std::mem::replace( + &mut r.host, + settled(scripted_as(Knobs::default(), (B_UFRAG, B_PWD))), + ); + let id = port.add(host).unwrap(); + // The candidate's first datagram is an answer, not a check: found by its path. + let (got, _) = port + .datagram(r.far.addr, &[0x80, 0x6f, 0, 1], r.now) + .unwrap(); + assert_eq!(got, id); + // RED: the refused candidate's address finds nothing. + assert!(port + .datagram(p(METADATA), &[0x80, 0x6f, 0, 1], r.now) + .is_none()); +} diff --git a/crates/busbar-core-connector/src/tests/udp_tests.rs b/crates/busbar-core-connector/src/tests/udp_tests.rs index befca262da..7464b33a3c 100644 --- a/crates/busbar-core-connector/src/tests/udp_tests.rs +++ b/crates/busbar-core-connector/src/tests/udp_tests.rs @@ -77,3 +77,35 @@ fn ipv6_loopback_binds_when_the_host_has_it() { assert_eq!(recv(&port), (b"v6".to_vec(), peer.local_addr())); } } + +/// RED: off a worker the port is not put on any reactor. +#[test] +fn a_ready_port_off_a_worker_is_refused() { + let e = ReadyPort::open(bind("127.0.0.1:0").unwrap()).expect_err("no worker, no reactor"); + assert_eq!(e.to_string(), crate::io::NOT_ON_A_WORKER); +} + +#[test] +fn a_ready_port_reads_on_readiness_and_sends_with_the_peer() { + crate::support::worker().block_on(async { + let port = ReadyPort::open(bind("127.0.0.1:0").unwrap()).unwrap(); + let far = bind("127.0.0.1:0").unwrap(); + let mut buf = vec![0_u8; MAX_DATAGRAM]; + let waker = std::task::Waker::noop(); + let mut cx = std::task::Context::from_waker(waker); + assert!( + port.poll_recv_from(&mut cx, &mut buf).is_pending(), + "nothing waiting: not ready, no block" + ); + far.try_send_to(b"check", port.local_addr()) + .unwrap() + .unwrap(); + let (n, peer) = futures::future::poll_fn(|cx| port.poll_recv_from(cx, &mut buf)) + .await + .unwrap(); + assert_eq!(&buf[..n], b"check"); + assert_eq!(peer, far.local_addr()); + assert!(port.send_to(b"answer", peer).unwrap()); + assert_eq!(recv(&far), (b"answer".to_vec(), port.local_addr())); + }); +} diff --git a/crates/busbar-core-connector/src/udp.rs b/crates/busbar-core-connector/src/udp.rs index f47d2e5c4c..95b9af8f63 100644 --- a/crates/busbar-core-connector/src/udp.rs +++ b/crates/busbar-core-connector/src/udp.rs @@ -106,6 +106,68 @@ impl UdpPort { } } +/// A [`UdpPort`] on the calling worker's reactor (`io.register`): the port the connector drives. +/// A read waits for the port's readiness, never a thread; a send that would block drops the +/// datagram (a datagram is never queued: the far end's own retransmission is the recovery). +#[derive(Debug)] +pub struct ReadyPort { + socket: crate::io::Registered, + local: SocketAddr, +} + +impl ReadyPort { + /// Put `port` on the calling worker's reactor. + /// + /// # Errors + /// + /// Not on a worker ([`crate::io::NOT_ON_A_WORKER`]), or the reactor refused it. + pub fn open(port: UdpPort) -> io::Result { + let local = port.local; + Ok(ReadyPort { + socket: crate::io::register(port.socket)?, + local, + }) + } + + /// The address the port is bound to. + #[must_use] + pub fn local_addr(&self) -> SocketAddr { + self.local + } + + /// The next datagram into `buf` and the peer it came from; Pending with `cx`'s waker + /// registered until one arrives. + /// + /// # Errors + /// + /// The socket or the reactor failed. + pub fn poll_recv_from( + &self, + cx: &mut std::task::Context<'_>, + buf: &mut [u8], + ) -> std::task::Poll> { + self.socket + .poll_io(crate::io::Direction::Read, cx, |s| s.recv_from(buf)) + } + + /// Send `datagram` to `to` now; `false` = the send buffer was full and it was dropped. + /// + /// # Errors + /// + /// The socket failed, or the datagram was sent short. + pub fn send_to(&self, datagram: &[u8], to: SocketAddr) -> io::Result { + match self.socket.get_ref().send_to(datagram, to) { + Ok(n) if n == datagram.len() => Ok(true), + Ok(_) => Err(io::Error::new( + io::ErrorKind::WriteZero, + "datagram sent short", + )), + Err(e) if e.kind() == io::ErrorKind::WouldBlock => Ok(false), + Err(e) => Err(e), + } + } +} + #[cfg(test)] #[path = "tests/udp_tests.rs"] mod tests; diff --git a/crates/busbar-core-oauth2/Cargo.toml b/crates/busbar-core-oauth2/Cargo.toml index 708ad10dc0..4e6d66ba95 100644 --- a/crates/busbar-core-oauth2/Cargo.toml +++ b/crates/busbar-core-oauth2/Cargo.toml @@ -2,7 +2,7 @@ # busbar-core into its own sibling crate. # # ONE-WAY DEPENDENCY (Cargo-enforced): this crate depends on busbar-core (to reach `CoreRouter`, -# `App`/`AppHandle`, `net_guard`, the diagnostics macros, and the `oauth_as::config` types the +# `App`/`AppHandle`, the diagnostics macros, and the `oauth_as::config` types the # single-document config pipeline still owns). busbar-core depends on NOTHING here — naming this # crate from core would be a dependency cycle, which Cargo refuses outright. Core reaches this # plane's runtime object only through the small seam in `busbar_core::oauth_as::seam` diff --git a/crates/busbar-core-oauth2/src/routes.rs b/crates/busbar-core-oauth2/src/routes.rs index d34370d017..83a39f9791 100644 --- a/crates/busbar-core-oauth2/src/routes.rs +++ b/crates/busbar-core-oauth2/src/routes.rs @@ -531,14 +531,14 @@ fn client_and_scope_of(target: &str) -> Option<(String, String, String)> { /// the credential, and a full URI puts an attacker-chosen path and query on the screen next to it, /// which is room to write text that argues with the page around it. /// -/// Read by the one shared URL reader ([`busbar_kernel::net_guard::parse_url`], WHATWG rules for +/// Read by the one shared URL reader ([`busbar_contract::net::parse_url`], WHATWG rules for /// `https`), so the host named here is the host the browser contacts: a userinfo before the last /// `@` is dropped (`https://client.example@evil.example/cb` names `evil.example`), and a `\` ends the /// authority (`https://evil.example\@trusted.example/cb` names `evil.example`, not the /// `trusted.example` a reader splitting only at `/` showed). A URI with no readable host names /// nothing, and the screen says so. fn host_of(redirect_uri: &str) -> String { - let Ok(parts) = busbar_kernel::net_guard::parse_url(redirect_uri) else { + let Ok(parts) = busbar_contract::net::parse_url(redirect_uri) else { return String::new(); }; let host = if parts.host.contains(':') { diff --git a/crates/busbar-kernel-egress/src/trust/tests/net_tests.rs b/crates/busbar-kernel-egress/src/trust/tests/net_tests.rs index f7aac247df..0f8cf1e1e0 100644 --- a/crates/busbar-kernel-egress/src/trust/tests/net_tests.rs +++ b/crates/busbar-kernel-egress/src/trust/tests/net_tests.rs @@ -1077,3 +1077,167 @@ fn the_structural_check_refuses_every_loopback_spelling() { ); } } + +// ── Moved from busbar-kernel's deleted `net_guard` shim suites (row 130 E2): the rows those suites +// held that this one did not. ─────────────────────────────────────────────────────────────────── + +/// The NAME arm is the ONLY defence for a metadata host that answers on a PUBLIC address. +/// +/// `ip_is_cloud_metadata` covers link-local /16 plus three literals plus IMDSv6. A metadata +/// endpoint reachable at a globally-routable address matches NONE of them, so if the name is not +/// in `METADATA_HOSTS` the resolve-then-pin path dials it. +#[test] +fn a_metadata_name_on_a_public_address_is_refused_by_its_name_alone() { + struct Stub(IpAddr); + impl Resolver for Stub { + fn resolve(&self, _host: &str) -> Result, String> { + Ok(vec![self.0]) + } + } + // Equinix Metal's metadata service answers on a public address, which is exactly why it is on + // the denylist by NAME: no address predicate can catch it. + let public = IpAddr::V4(Ipv4Addr::new(147, 75, 1, 1)); + let got = resolve_and_pin( + "metadata.platformequinix.com", + 443, + true, + &Stub(public), + GuardPolicy::default(), + ); + assert!( + got.is_err(), + "metadata.platformequinix.com resolved to a PUBLIC address was PINNED, not refused: {got:?}" + ); +} + +/// THE HOST-STRING GUARDS MUST JUDGE THE NAT64 EMBEDDING TOO, not just the address predicates. +/// +/// [`ip_is_cloud_metadata`] and [`ip_is_internal`] take an already-parsed `IpAddr` and were fixed +/// first. The three guards below take a HOST STRING and parse it themselves, and each kept its own +/// `to_ipv4()` unwrap — so `64:ff9b::a9fe:a9fe` parsed clean, matched no v6 range, and read as an +/// ordinary public address. That gap was live after the address-level fix landed, which is exactly +/// why this test exists separately from +/// `nat64_embedded_ipv4_is_judged_by_the_shared_predicates`: fixing the predicate does NOT fix the +/// callers that never call it. +/// +/// [`ssrf_blocked_host`] is the pre-flight guard for OAuth token-endpoint URLs and for +/// tool-call ARGUMENT hosts — attacker-influenced input on the classic SSRF injection path — so a +/// bypass here is reachable without any DNS answer at all. +#[test] +fn nat64_embedded_ipv4_is_judged_by_the_host_string_guards() { + // Every spelling of the IMDS target 169.254.169.254 that a caller can hand us. + for meta in [ + "169.254.169.254", // bare v4 + "::ffff:169.254.169.254", // v4-mapped + "64:ff9b::a9fe:a9fe", // NAT64 well-known (RFC 6052) + "64:ff9b:1::a9fe:a9fe", // NAT64 local-use (RFC 8215), zero middle + "64:ff9b:1:fffe::a9fe:a9fe", // NAT64 local-use, non-zero middle + ] { + let url = format!("http://[{meta}]/latest/meta-data/"); + let url = if meta.contains(':') { + url + } else { + format!("http://{meta}/latest/meta-data/") + }; + assert!( + ssrf_blocked_host(&url, &[], false, &[]).is_some(), + "{meta} reaches the cloud-metadata service and must be refused pre-flight; \ + a NAT64 spelling is the same target as the bare form" + ); + } + + // The private/loopback host predicate must see through the embedding as well. + for internal in [ + "64:ff9b::7f00:1", // 127.0.0.1 + "64:ff9b::a01:203", // 10.1.2.3 + "64:ff9b:1:fffe::c0a8:1", // 192.168.0.1 via local-use NAT64 + ] { + assert!( + host_is_private_or_loopback(internal), + "{internal} is a NAT64 embedding of an internal target and must read as internal" + ); + } + + // An operator denylist entry written in the bare v4 form must also catch the NAT64 spelling — + // otherwise the denylist is trivially evaded by re-encoding the same address. Asked through the + // operator's OWN door rather than through the canonicalizer, and asked on an address the + // hardcoded denylist does NOT hold (`10.99.99.99` is a legitimate upstream by default), so the + // refusal can only be coming from the entry: the control below shows the same host passing when + // the list is empty. + const NAT64_OF_10_99_99_99: &str = "https://[64:ff9b::a63:6363]/"; + assert!( + ssrf_blocked_host( + NAT64_OF_10_99_99_99, + &[], + false, + &["10.99.99.99".to_string()] + ) + .is_some(), + "a denylist entry naming the v4 address must match its NAT64 embedding" + ); + assert!( + ssrf_blocked_host(NAT64_OF_10_99_99_99, &[], false, &[]).is_none(), + "the control: without the operator entry this host is an ordinary private upstream, so the \ + assertion above is about the entry and not about the hardcoded denylist" + ); + + // Public addresses are unaffected: 8.8.8.8 embedded in NAT64 is still public. + assert!( + !host_is_private_or_loopback("64:ff9b::808:808"), + "64:ff9b::808:808 embeds public 8.8.8.8 and must NOT read as internal" + ); +} + +/// THE DIALING GUARD AND THE CONFIG GUARD MUST KNOW THE SAME METADATA NAMES. +/// +/// They did not. `ssrf_blocked_host` kept a SIX-name list private to its own body while +/// `judge_host_name` — the arm the resolve-then-pin dialing path consults — read a TWO-name +/// module const. Config validation refused `metadata.tencentyun.com`; the socket did not. +#[test] +fn the_dialing_guard_and_the_config_guard_know_the_same_metadata_names() { + // Every name `ssrf_blocked_host` (config validation) refuses must ALSO be refused by + // `judge_host_name`, which is the arm the resolve-then-pin dialing path actually consults. + for name in [ + "metadata.google.internal", + "metadata.internal", + "metadata.tencentyun.com", + "metadata.platformequinix.com", + "instance-data", + "instance-data.ec2.internal", + ] { + // Config-validation view. + let cfg = ssrf_blocked_host(&format!("https://{name}/"), &[], false, &[]); + // Dialing view — the one a socket is opened behind. + let dial = judge_host_name(name, GuardPolicy::default()); + assert!( + cfg.is_some(), + "{name}: config guard should refuse (this is the 6-name list)" + ); + assert!( + dial.is_err(), + "{name}: DIALING guard did NOT refuse — config refuses it but the dial path allows it" + ); + } +} + +/// NAT64 / RFC 6052 END TO END: a DNS64-answered destination must be refused as metadata, not +/// pinned as an ordinary public v6 address, running through the SAME resolve-then-pin guard every +/// other rebinding case in this file is driven through. +/// +/// A DNS64 resolver on an IPv6-only network answers a AAAA query with the NAT64 synthesis of the +/// queried name's IPv4 address. `64:ff9b:1:fffe::a9fe:a9fe` is the RFC 8215 local-use synthesis +/// (with a non-zero middle, the shape RFC 8215 Section 6's own worked example uses) of the IMDS +/// target `169.254.169.254`; it matches no v6 range and no `to_ipv4()` form, so a guard unwrapping +/// only `to_ipv4()` would pin it as an ordinary public address. +#[test] +fn nat64_dns64_synthesis_of_imds_is_refused_even_under_allow_private() { + let r = ScriptedResolver::new(vec![Ok(vec![ip("64:ff9b:1:fffe::a9fe:a9fe")])]); + let err = resolve_and_pin("dns64.example", 443, true, &r, private_ok()).expect_err( + "the RFC 8215 local-use NAT64 synthesis of the IMDS target must be refused even under \ + allow_private", + ); + assert!( + matches!(err, AddressRefusal::CloudMetadataAddress { .. }), + "must be refused AS METADATA, not merely pinned as an ordinary public address: {err:?}" + ); +} diff --git a/crates/busbar-kernel/Cargo.toml b/crates/busbar-kernel/Cargo.toml index e0aa9b862d..7686a771b0 100644 --- a/crates/busbar-kernel/Cargo.toml +++ b/crates/busbar-kernel/Cargo.toml @@ -42,9 +42,9 @@ busbar-contract = { path = "../busbar-contract" } # THE ONE NETWORK JUDGE (#36: "the upstream leg — walk + swrr weighting + pre-dial guard; trust # folded in"). `busbar_kernel_egress::trust::net` is the single definition of the SSRF/metadata # guard — the metadata name list, the address predicates, the WHATWG host extraction, the -# resolve-then-pin. `src/net_guard.rs` is a re-export shim over it, so every in-core call site and -# all three plane crates keep naming `busbar_kernel::net_guard::…` unchanged and no plane→unit edge -# is created. There WAS a second copy of that guard in this crate; it drifted, and the drift was a +# resolve-then-pin. Every in-core call site names it in its unit home (the pure host atoms in +# `busbar_contract::net`); the kernel resolves no name itself (row 130 E2: the resolver is the +# connector's, behind the egress-trust capability). There WAS a second copy of that guard in this crate; it drifted, and the drift was a # live metadata bypass (`metadata.platformequinix.com` resolves and pins to a routable address, so # nothing but the NAME arm can refuse it, and the name arm read a two-entry list). One judge is the # fix; this edge is what makes one judge reachable from here. @@ -86,14 +86,13 @@ busbar-kernel-scope = { path = "../busbar-kernel-scope" } # dump, and only under the non-default `timing` feature. The default build does not link it. libc = { workspace = true, optional = true } busbar-plugin-loader = { path = "../plugin-loader" } -# THE STAND-IN LINKED ROWS (test-only edges): the composition root links the build's default store -# and ranking hooks onto the cold-kind axis (`preflight::install_linked_rows`); a test build has no -# root, so these stand in, under `test-support` (which the dev-dependency cycle through the plane -# crates turns on for this crate's own tests). The store is a fixture, renamed to the role it plays: -# no source here names the plugin. The ranking row is no crate at all: it is the in-crate R-FIX3 -# double `test_support::ranking_double`, linked only, and the root proves it states what the real -# ranking door states. -fixture-store = { workspace = true, optional = true } +# THE STAND-IN LINKED ROWS are no crate at all: the composition root links the build's store and +# ranking hooks onto the cold-kind axis (`preflight::install_linked_rows`); a test build has no root, +# so the in-crate R-FIX3 doubles stand in, linked only — `test_support::store_double` (the store row +# and the `MemoryStore` the kernel's tests build a `GovState` over) and `test_support::ranking_double`. +# The kernel names no store crate under any name (THE DESIGN Part 2 #2: the kernel reaches a store +# only through the store kind's door), and the root proves each double states what the real door +# states. axum = { workspace = true } tower = { workspace = true } tokio = { workspace = true, features = ["rt-multi-thread", "macros", "net", "time", "sync", "signal", "io-util", "io-std", "process"] } @@ -242,7 +241,7 @@ loom-model = ["dep:loom"] # Exposes `test_support` to a DEPENDENT crate's tests (the bin's). Never on in a normal build; # the bin enables it from its [dev-dependencies] edge only. The contract's test double (its warn # capture) is always compiled (ARCHITECT SD-3 queue (4)), so nothing forwards there. -test-support = ["dep:reqwest", "dep:rcgen", "dep:fixture-store", "busbar-kernel-identity/stand-in", "busbar-plugin-loader/test-support"] +test-support = ["dep:reqwest", "dep:rcgen", "busbar-kernel-identity/stand-in", "busbar-plugin-loader/test-support"] # The debug-only per-method micro-timing in `src/timing` (folded from busbar-timing, OWNER Q70). # DEFAULT OFF: with this off, `timeit!` expands to `()` and the RAII guards it produces are # zero-sized no-ops, so the `drop(_guard)` calls that end a timed span are compiled in ONLY under this diff --git a/crates/busbar-kernel/src/appbuild.rs b/crates/busbar-kernel/src/appbuild.rs index 005ce204dd..24e1c61613 100644 --- a/crates/busbar-kernel/src/appbuild.rs +++ b/crates/busbar-kernel/src/appbuild.rs @@ -26,8 +26,8 @@ use crate::store::{HealthState, LaneData}; use crate::{ admin, audit, auth, billing, breaker, catalogue, config, config_validate, core_routes, cost, durable, endpoints, export, failover, governance, handlers, hooks, ingress, ir, json, limits, - net_guard, oauth_as, observability, operation, plane, plugin_routes, profile, proto, proxy, - ratelimit, snapshot, state, store, telemetry, tls, transport, trust, + oauth_as, observability, operation, plane, plugin_routes, profile, proto, proxy, ratelimit, + snapshot, state, store, telemetry, tls, transport, trust, }; use busbar_kernel::plane_host::{ AffinityInput, AuthStyleInput, ClientSettingsInput, FailoverInput, HealthInput, diff --git a/crates/busbar-kernel/src/audit/tests/journal_tests.rs b/crates/busbar-kernel/src/audit/tests/journal_tests.rs index 882366a724..4e5fb256b0 100644 --- a/crates/busbar-kernel/src/audit/tests/journal_tests.rs +++ b/crates/busbar-kernel/src/audit/tests/journal_tests.rs @@ -755,10 +755,10 @@ fn an_evicted_neutral_scopes_tampered_tail_surfaces_a_break_on_resume() { /// AUDIT-INTEGRITY FIX: `append_scoped` hardcoded every row's `PlaneRecord::ts` to `0`, which defeats /// retention entirely — `purge_plane_records_before` drops any row with `r.ts < before`, so a `ts` of /// 0 makes every row look infinitely old and the FIRST retention sweep deletes the whole class -/// regardless of how recently it was written. Proven over the REAL `busbar-store-memory` backend -/// (not this file's in-RAM `MockStore`, whose `purge_plane_records_before` is a stub that always -/// answers 0 and so cannot exercise this): a record appended just now must SURVIVE a retention sweep -/// whose cutoff is safely in the past. +/// regardless of how recently it was written. Proven over the kernel's store double, which carries +/// the shipped RAM store's retention (not this file's in-RAM `MockStore`, whose +/// `purge_plane_records_before` is a stub that always answers 0 and so cannot exercise this): a +/// record appended just now must SURVIVE a retention sweep whose cutoff is safely in the past. #[test] fn append_scoped_stamps_a_real_instant_so_a_fresh_row_survives_retention() { let store: Arc = diff --git a/crates/busbar-kernel/src/config_validate/mod.rs b/crates/busbar-kernel/src/config_validate/mod.rs index cfdddfc20e..d64f0dad55 100644 --- a/crates/busbar-kernel/src/config_validate/mod.rs +++ b/crates/busbar-kernel/src/config_validate/mod.rs @@ -51,15 +51,15 @@ const MAX_SEMAPHORE_PERMITS: usize = tokio::sync::Semaphore::MAX_PERMITS; const MAX_DURATION_SECS: u64 = 30 * 365 * 86_400; /// [`MAX_DURATION_SECS`] for the millisecond-denominated knobs. const MAX_DURATION_MS: u64 = MAX_DURATION_SECS * 1_000; -// SSRF host guards relocated DOWN into the neutral `busbar-substrate` net_guard leaf (Batch A), -// re-exported here so every in-core caller keeps naming `config_validate::{…}` unchanged and the -// two SSRF guards still single-source their byte-identical atoms. -pub use crate::net_guard::{ - extract_normalized_host, host_is_private_or_loopback, scheme_is, ssrf_blocked_host, -}; +// SSRF host guards: the pure host atoms are `busbar_contract::net`'s and the pre-flight guard is the +// egress unit's (`busbar_kernel_egress::trust::net`), re-exported here so every in-core caller keeps +// naming `config_validate::{…}` unchanged and the two SSRF guards still single-source their +// byte-identical atoms. +pub use busbar_contract::net::{extract_normalized_host, host_is_private_or_loopback, scheme_is}; +pub use busbar_kernel_egress::trust::net::ssrf_blocked_host; // Test-only: the alternate-IPv4 expander moved with the guards; its unit tests still name it here. #[cfg(test)] -use crate::net_guard::expand_alternate_ipv4; +use busbar_contract::net::expand_alternate_ipv4; /// Validate the loaded configuration and collect all errors at once. /// Returns Ok(()) if valid; Err(Vec) with all validation failures otherwise. @@ -2588,7 +2588,7 @@ pub fn service_account_token_uri(credential: &str) -> Result { /// /// The endpoint breaks either rule; the text is 1.5.5's. pub fn vet_token_uri(token_uri: &str, posture: &MetadataPosture) -> Result<(), String> { - use crate::net_guard::{extract_normalized_host, host_is_private_or_loopback, scheme_is}; + use busbar_contract::net::{extract_normalized_host, host_is_private_or_loopback, scheme_is}; let host_private = extract_normalized_host(token_uri) .as_deref() .map(host_is_private_or_loopback) diff --git a/crates/busbar-kernel/src/egress/duplex_ws.rs b/crates/busbar-kernel/src/egress/duplex_ws.rs index 8604f06bd8..37466e32e0 100644 --- a/crates/busbar-kernel/src/egress/duplex_ws.rs +++ b/crates/busbar-kernel/src/egress/duplex_ws.rs @@ -14,7 +14,7 @@ //! //! A `wss://` upstream is an operator/runtime target, so it is resolved-then-pinned-then-guarded on //! EXACTLY the discipline the HTTP egress seam applies — never a raw `connect_async` that resolves DNS -//! itself. The order is [`crate::net_guard::resolve_and_pin`] FIRST (structural refusals, one +//! itself. The order is [`net::resolve_and_pin`] FIRST (structural refusals, one //! resolution, every answered address judged, the survivor pinned), then a TCP connect to THAT pinned //! address, then a TLS handshake presenting the URL host for SNI / certificate validation, then the //! client WS handshake OVER that already-guarded stream. The socket is never opened to anything the @@ -27,7 +27,7 @@ use futures::{Sink, SinkExt, Stream, StreamExt}; use tokio::net::TcpStream; use tokio_tungstenite::tungstenite::Message; -use crate::net_guard::{self, AddressRefusal, GuardPolicy}; +use busbar_kernel_egress::trust::net::{self, AddressRefusal, GuardPolicy}; /// How many frames an upstream may have in flight ahead of the leg reading them. The same reasoning /// the inbound acceptor's queue is bounded by, pointed the other way: an upstream that emits faster @@ -161,7 +161,7 @@ impl From for DialError { /// `wss://host[:port]/path` (or `ws://…`) split into `(secure, host, port, request-url)`. A strict /// recogniser over the scheme, with the authority read by the one shared URL reader -/// ([`net_guard::parse_url`], WHATWG rules) — so it ends at `/`, `?`, `#` or `\` exactly where the +/// ([`net::parse_url`], WHATWG rules) — so it ends at `/`, `?`, `#` or `\` exactly where the /// handshake's own parser ends it, and the host the guard pins is the host the handshake names. The /// request-url handed to the handshake keeps the original `ws(s)` scheme so the `Host` / /// `Sec-WebSocket-*` headers are exactly what the upstream expects. @@ -173,7 +173,7 @@ fn split_ws_url(url: &str) -> Result<(bool, String, u16, String), DialError> { } else { return Err(DialError::Url(DialTarget::new(url))); }; - let parts = net_guard::parse_url(url).map_err(|_| DialError::Url(DialTarget::new(url)))?; + let parts = net::parse_url(url).map_err(|_| DialError::Url(DialTarget::new(url)))?; // No userinfo in an egress target: an `@` is an unusable authority, exactly as the HTTP guard // treats it. if parts.userinfo { @@ -239,8 +239,13 @@ pub async fn dial_with_credential( // THE GUARD, FIRST — resolve then pin then judge. `https = secure`: a `ws://` target is judged as // plaintext (admitted only under the policy's plaintext stance), a `wss://` as TLS. No socket is // opened to anything this did not pin. - let pinned = - net_guard::resolve_and_pin(&host, port, secure, &net_guard::SystemResolver, policy)?; + let pinned = net::resolve_and_pin( + &host, + port, + secure, + &crate::egress::trust::InstalledResolver, + policy, + )?; // The upgrade request — the only place the secret is written, built after the guard has judged // the keyless target and before any socket is opened. diff --git a/crates/busbar-kernel/src/egress/fixtures.rs b/crates/busbar-kernel/src/egress/fixtures.rs index cf94340739..cec59fb18b 100644 --- a/crates/busbar-kernel/src/egress/fixtures.rs +++ b/crates/busbar-kernel/src/egress/fixtures.rs @@ -949,3 +949,39 @@ pub fn loopback_literal_listed(host: &str) -> Result<(), crate::host_services::D pub fn install_test_tls(layer: Arc) { crate::egress::trust::install_egress_trust_host(Arc::new(TlsEgressTrust(layer))); } + +/// THE SYSTEM LOOKUP a test build's kernel resolve-then-pin answers with when no guarded egress-trust +/// capability is installed (`egress::trust::EgressTrustHost::resolve_names`): every +/// address `host` answers with, de-duplicated but otherwise untouched and unsorted. Test machinery +/// only: a production kernel resolves nothing itself, the connector's resolver +/// (`busbar_core_connector::hostio::SystemNames`, the same body) is installed behind the capability. +/// +/// # Errors +/// +/// The resolution failure. +pub fn system_names(host: &str) -> Result, String> { + let target = format!("{host}:0"); + std::thread::scope(|s| { + s.spawn(|| { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_err(|e| format!("name lookup: could not start a runtime: {e}"))?; + rt.block_on(async move { + let answered = tokio::net::lookup_host(target) + .await + .map_err(|e| e.to_string())?; + let mut out: Vec = Vec::new(); + for sa in answered { + let ip = sa.ip(); + if !out.contains(&ip) { + out.push(ip); + } + } + Ok(out) + }) + }) + .join() + .map_err(|_| "name lookup: the worker thread panicked".to_string())? + }) +} diff --git a/crates/busbar-kernel/src/egress/mod.rs b/crates/busbar-kernel/src/egress/mod.rs index e89a900548..92b74065f6 100644 --- a/crates/busbar-kernel/src/egress/mod.rs +++ b/crates/busbar-kernel/src/egress/mod.rs @@ -35,7 +35,7 @@ pub mod trust; // `crate::proxy::` paths. pub mod engine; -// THE NEUTRAL FULL-DUPLEX WS EGRESS DIALER — dial an upstream `wss://` THROUGH `net_guard` +// THE NEUTRAL FULL-DUPLEX WS EGRESS DIALER — dial an upstream `wss://` THROUGH the egress unit's guard // (resolve→pin→guard, then TCP to the pinned address, then TLS with the URL host for SNI, then the WS // handshake) and hand back the `Stream`/`Sink>` pair `byte_duplex::serve_messages` consumes. // The outbound half of the WS transport `Transport::WebSocket` selects; armed under `runtime`. This is diff --git a/crates/busbar-kernel/src/egress/tests/duplex_ws_tests.rs b/crates/busbar-kernel/src/egress/tests/duplex_ws_tests.rs index 3d79d2048a..bcb3b901d9 100644 --- a/crates/busbar-kernel/src/egress/tests/duplex_ws_tests.rs +++ b/crates/busbar-kernel/src/egress/tests/duplex_ws_tests.rs @@ -15,8 +15,8 @@ use futures::{SinkExt, StreamExt}; use crate::egress::duplex_ws::{self, DialError}; use crate::ingress::byte_duplex::{CallRef, DuplexHandle, DuplexPlane}; use crate::ingress::duplex_ws as ws_ingress; -use crate::net_guard::{AddressRefusal, GuardPolicy}; use crate::transport::{Transport, UpstreamWireKind}; +use busbar_kernel_egress::trust::net::{AddressRefusal, GuardPolicy}; /// A trivial ECHO plane bound to the acceptor: no protocol, no wire vocabulary — it echoes each frame /// back verbatim. Exactly the shape the `serve_messages` header describes an upgraded WS session taking. diff --git a/crates/busbar-kernel/src/egress/trust/mod.rs b/crates/busbar-kernel/src/egress/trust/mod.rs index cfc54137bc..a793521538 100644 --- a/crates/busbar-kernel/src/egress/trust/mod.rs +++ b/crates/busbar-kernel/src/egress/trust/mod.rs @@ -45,6 +45,7 @@ use crate::config::Destinations; use crate::egress::engine::ClientIdentity; use crate::host_services::{DestJudge, DestRefusal}; use crate::secure::SecureLayer; +use busbar_kernel_egress::trust::net::Resolver; use spki::SpkiError; /// THE OUTBOUND EGRESS-TRUST HOST CAPABILITY, as a neutral trait a plane reaches through instead of @@ -124,6 +125,50 @@ pub trait EgressTrustHost: Send + Sync { fn secure_layer(&self) -> Option> { None } + + /// THE NAME RESOLUTION a kernel dial's resolve-then-pin asks for (row 130 E2): every address + /// `host` answers with, de-duplicated, unsorted, unfiltered (the guard judges all of them). The + /// lookup is the connector's (`busbar_core_connector::hostio::SystemNames`): the kernel opens no + /// socket, a resolver's included. The pass-through has none behind it, so a production build + /// that installed no guarded capability refuses every name (FAIL CLOSED); a test build resolves + /// through the test fixtures' system lookup. + /// + /// # Errors + /// + /// The resolution failure (not an empty answer: the two are different facts). + fn resolve_names(&self, host: &str) -> Result, String> { + unresolved(host) + } +} + +/// [`EgressTrustHost::resolve_names`] with no resolver behind it: a test build resolves through the +/// fixtures' system lookup; a production build refuses the name. +fn unresolved(host: &str) -> Result, String> { + #[cfg(any(test, feature = "test-support"))] + { + crate::egress::fixtures::system_names(host) + } + #[cfg(not(any(test, feature = "test-support")))] + { + Err(format!( + "name lookup: `{host}` was not resolved: no resolver is installed" + )) + } +} + +/// THE RESOLVER a kernel resolve-then-pin hands the guard (`resolve_and_pin`): the installed +/// capability's [`EgressTrustHost::resolve_names`], or, with none installed, the same answer the +/// pass-through gives. +#[derive(Clone, Copy, Debug, Default)] +pub struct InstalledResolver; + +impl Resolver for InstalledResolver { + fn resolve(&self, host: &str) -> Result, String> { + match egress_trust_host() { + Some(capability) => capability.resolve_names(host), + None => unresolved(host), + } + } } /// The production egress-trust capability: a BYTE-FOR-BYTE pass-through to the host-side primitives. @@ -134,14 +179,21 @@ pub struct PassThroughEgressTrust; impl EgressTrustHost for PassThroughEgressTrust {} /// The capability the composition root installs: the pass-through primitives, every answer judged by -/// the deployment's one destination guard, and every outbound connection secured by the one TLS -/// wrap (both the connector's; this decides nothing). -pub struct GuardedEgressTrust(pub Arc, pub Arc); +/// the deployment's one destination guard, every outbound connection secured by the one TLS wrap, +/// and every name resolved by the one resolver (all three the connector's; this decides nothing). +pub struct GuardedEgressTrust( + pub Arc, + pub Arc, + pub Arc, +); impl EgressTrustHost for GuardedEgressTrust { fn secure_layer(&self) -> Option> { Some(Arc::clone(&self.1)) } + fn resolve_names(&self, host: &str) -> Result, String> { + self.2.resolve(host) + } fn judge_answer(&self, host: &str, addrs: &[IpAddr], class: u32) -> Result<(), DestRefusal> { self.0.judge_answer(host, addrs, class) } diff --git a/crates/busbar-kernel/src/egress/trust/tests/egress_trust_tests.rs b/crates/busbar-kernel/src/egress/trust/tests/egress_trust_tests.rs index 7f69c4a0a5..80b512ab61 100644 --- a/crates/busbar-kernel/src/egress/trust/tests/egress_trust_tests.rs +++ b/crates/busbar-kernel/src/egress/trust/tests/egress_trust_tests.rs @@ -134,9 +134,45 @@ fn the_guarded_seam_judges_by_the_guard_behind_it() { let judged = GuardedEgressTrust( crate::egress::fixtures::private_refusing(&[]), crate::egress::fixtures::TlsDouble::default().layer(), + std::sync::Arc::new(Answers(Vec::new())), ); let private: std::net::IpAddr = "10.0.0.5".parse().unwrap(); let public: std::net::IpAddr = "93.184.216.34".parse().unwrap(); assert!(judged.judge_answer("db.test", &[private], 0).is_err()); assert_eq!(judged.judge_answer("api.test", &[public], 0), Ok(())); } + +/// A resolver that answers every name with the same addresses, and an empty list as a failure. +struct Answers(Vec); + +impl busbar_kernel_egress::trust::net::Resolver for Answers { + fn resolve(&self, host: &str) -> Result, String> { + if self.0.is_empty() { + return Err(format!("{host}: no answer")); + } + Ok(self.0.clone()) + } +} + +/// Row 130 E2: the root's capability resolves every name through the resolver behind it (the +/// connector's), answering exactly what it answered, and its failure as a failure; the kernel +/// resolves nothing itself. +#[test] +fn the_guarded_seam_resolves_by_the_resolver_behind_it() { + let public: std::net::IpAddr = "93.184.216.34".parse().unwrap(); + let guarded = |answers: Vec| { + GuardedEgressTrust( + crate::egress::fixtures::private_refusing(&[]), + crate::egress::fixtures::TlsDouble::default().layer(), + std::sync::Arc::new(Answers(answers)), + ) + }; + assert_eq!( + guarded(vec![public]).resolve_names("api.test"), + Ok(vec![public]) + ); + assert_eq!( + guarded(Vec::new()).resolve_names("api.test"), + Err("api.test: no answer".to_owned()) + ); +} diff --git a/crates/busbar-kernel/src/governance/mod.rs b/crates/busbar-kernel/src/governance/mod.rs index e2fc360024..935e976b0f 100644 --- a/crates/busbar-kernel/src/governance/mod.rs +++ b/crates/busbar-kernel/src/governance/mod.rs @@ -1508,10 +1508,10 @@ impl IntoStoreResult for Result { } } -// The universal test double: the build's default store, which the composition root links onto the -// store axis (the kernel names no store); a test build links it as its store fixture. +// The universal test double: the in-crate R-FIX3 store double (`test_support::store_double`), which a +// test build also links as its stand-in store row. The kernel names no store crate. #[cfg(any(test, feature = "test-support"))] -pub use fixture_store::MemoryStore; +pub use crate::test_support::store_double::MemoryStore; /// The write-behind flusher: on a fixed cadence (and once more on graceful shutdown) pushes the /// dirty in-memory budget cells and the accumulated `pending_metering` rows to the durable store off diff --git a/crates/busbar-kernel/src/governance/state.rs b/crates/busbar-kernel/src/governance/state.rs index 4398ffb482..58bffd5a39 100644 --- a/crates/busbar-kernel/src/governance/state.rs +++ b/crates/busbar-kernel/src/governance/state.rs @@ -480,17 +480,16 @@ impl GovState { /// is read. /// /// Deliberately returns the secret rather than a ready-made HMAC, so this module keeps knowing - /// nothing about what any consumer seals: a plane's ask-state codec - /// ([`crate::plane::approvals::Sealer::derive`]) does its own domain-separated derivation, which is - /// what keeps its blobs and this module's virtual-key tokens unable to verify as one another. - /// Secret-equivalent: never log it, never put it in a `Debug`. + /// nothing about what any consumer seals: a consumer does its own domain-separated derivation, + /// which is what keeps its blobs and this module's virtual-key tokens unable to verify as one + /// another. Secret-equivalent: never log it, never put it in a `Debug`. /// /// The FLEET-SHARED property is the reason this is usable for a state seal at all. One logical /// caller-facing exchange spans several independent requests, which different nodes may serve; /// a per-process key would make the second request fail on whichever node did not mint the first. - // Its consumer is the ask-state sealer (`crate::plane::approvals::Sealer`), driven by whichever - // plane seals caller-facing state; with no such plane installed it has no caller. Unconditional - // allow — the neutral seam names no plane feature. + // No in-core consumer: a plane seals its caller-facing state over the host's `sign` (ARCHITECT + // Q-L3B-ASK; the in-core ask-state sealer is deleted, audit K2-H3). Unconditional allow — the + // neutral seam names no plane feature. #[allow(dead_code)] pub fn signing_secret(&self) -> Option<[u8; 32]> { self.signing_material().map(|m| m.signer.secret_bytes()) diff --git a/crates/busbar-kernel/src/handlers/mod.rs b/crates/busbar-kernel/src/handlers/mod.rs index 384130c52b..26b6b29cff 100644 --- a/crates/busbar-kernel/src/handlers/mod.rs +++ b/crates/busbar-kernel/src/handlers/mod.rs @@ -50,9 +50,9 @@ // `busbar_mcp::PROTO_DECL` directly (dev-dependency). NOTE THE SCOPE: this was MCP the PROTOCOL; the // `mcp/` PLANE (`crate::mcp`) never travelled with the codec and is still core's. -// THE USAGE-TAP FAULT HOST SERVICES the protocol install arms for every codec cell (#83a HOST): -// the warn-once latch and the decode reporter, both counting on -// `busbar_kernel::snapshot::BILLING_TAP_DECODE_FAIL_TOTAL`. +// THE USAGE-TAP FAULT COUNT (row 155): a plane returns a usage-tap fault as data on its door's +// answer (`abi::plane::OnPieceOut::tap_fault`), and the kernel counts it here on +// `busbar_kernel::snapshot::BILLING_TAP_DECODE_FAIL_TOTAL`, warning once per `(protocol, reason)`. /// Process-lifetime warn-once latch for the usage-tap decode fault class, keyed `protocol:reason`. A /// live protocol/dialect the tap reader cannot decode fails on EVERY 2xx body of that shape, so an @@ -72,30 +72,25 @@ pub fn usage_tap_decode_fail_should_warn(protocol: &str, reason: &'static str) - seen.insert(format!("{protocol}:{reason}")) } -/// THE HOST'S USAGE-TAP FAULT REPORTER — what [`OperationHandler::extract_usage`](busbar_contract::codec::OperationHandler::extract_usage)'s default reports -/// through when a cell's own reader refuses a same-protocol 2xx body (the request bills 0 tokens). -/// Counted on [`BILLING_TAP_DECODE_FAIL_TOTAL`](crate::snapshot::BILLING_TAP_DECODE_FAIL_TOTAL) and warned once per `(protocol, reason)`, exactly as -/// the default did inline before the trait moved into the contract, which takes no logging or metrics -/// dependency. Installed by [`crate::proto::install_protocols`] and the test registration seams, so -/// it is armed before any cell is reachable. -pub fn report_usage_tap_decode_failure( - ingress_protocol: &str, - e: &busbar_contract::codec::CodecError, -) { - if usage_tap_decode_fail_should_warn(ingress_protocol, "decode") { +/// COUNT ONE USAGE-TAP FAULT a plane's answer carried for a unit read in `protocol`: one increment +/// of [`BILLING_TAP_DECODE_FAIL_TOTAL`](crate::snapshot::BILLING_TAP_DECODE_FAIL_TOTAL) under +/// `reason`, warned the first time the `(protocol, reason)` is seen and logged at debug after. The +/// request billed 0 tokens for its tap; the plane names the fault, the kernel counts it. +pub fn usage_tap_fault(protocol: &str, reason: &'static str) { + if usage_tap_decode_fail_should_warn(protocol, reason) { crate::diagnostics::diag_warn!( crate::diagnostics::USAGE_TAP_DECODE_FAILED, - protocol = ingress_protocol, - error = ?e, - "usage tap: read_response failed to decode a same-protocol 2xx body; \ + protocol = protocol, + reason = reason, + "usage tap: a same-protocol 2xx body's usage could not be read; \ billing 0 tokens for this request" ); } else { crate::diagnostics::diag_debug!( crate::diagnostics::USAGE_TAP_DECODE_FAILED, - protocol = ingress_protocol, - error = ?e, - "usage tap: read_response still failing to decode a same-protocol 2xx body; \ + protocol = protocol, + reason = reason, + "usage tap: a same-protocol 2xx body's usage still could not be read; \ billing 0 tokens for this request" ); } @@ -192,7 +187,8 @@ impl OpDispatch { &self, ingress_protocol: &str, body: &[u8], - ) -> Option { + ) -> Result, busbar_contract::codec::UsageTapFault> + { self.op_handler.extract_usage(ingress_protocol, body) } pub fn egress_accept(&self, egress_protocol: &str, wants_stream: bool) -> &'static str { diff --git a/crates/busbar-kernel/src/host_services.rs b/crates/busbar-kernel/src/host_services.rs index 8f3c71358d..fe07d8a5ec 100644 --- a/crates/busbar-kernel/src/host_services.rs +++ b/crates/busbar-kernel/src/host_services.rs @@ -9,7 +9,7 @@ //! //! * `clock.now` — the kernel's clock: wall time, and monotonic time from an origin fixed when the //! services are built. -//! * `dest.judge` — the ONE destination judge, the egress unit's (through `net_guard`): the +//! * `dest.judge` — the ONE destination judge, the egress unit's (`busbar_kernel_egress::trust::net`): the //! metadata denylist, then the refusals the name decides, //! answered at once and before any resolution, as 1.5.5 answered them. Asked to resolve //! (`DEST_RESOLVE`), a name is resolved off the caller's thread, the call pends, and the address diff --git a/crates/busbar-kernel/src/lib.rs b/crates/busbar-kernel/src/lib.rs index 2f3e28886e..4d9e0f9f2a 100644 --- a/crates/busbar-kernel/src/lib.rs +++ b/crates/busbar-kernel/src/lib.rs @@ -216,7 +216,7 @@ pub use busbar_plugin_loader::durable; // The host-owned outbound surface: the neutral SSRF-pinned client (re-exported wholesale from // busbar-substrate) plus the host-mediated `seam` adapter that drives egress through the // `plane_host` FFI vtable, gated behind the neutral `egress-seam` capability feature rather than -// any one plane. Always compiled, like `net_guard`, because the host owns every outbound byte +// any one plane. Always compiled, because the host owns every outbound byte // whether or not a plane needing the seam is built. See the module header. /// A lane's credential, bound by the auth plugin that serves its style (P2 D1, the auth split). pub mod bound_credential; @@ -246,7 +246,6 @@ pub mod host_services; pub mod json; pub mod limits; pub mod lineage; -pub mod net_guard; pub mod oauth_as; pub mod observability; pub mod snapshot; diff --git a/crates/busbar-kernel/src/net_guard.rs b/crates/busbar-kernel/src/net_guard.rs deleted file mode 100644 index a066e9c7a6..0000000000 --- a/crates/busbar-kernel/src/net_guard.rs +++ /dev/null @@ -1,116 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// Copyright (C) 2026 Busbar Inc and contributors - -//! Re-export shim over THE ONE NETWORK JUDGE, plus the one part of it a pure crate cannot hold. -//! -//! ## There was a second copy of this file, and the copy was a live bypass -//! -//! Until this commit `net_guard.rs` was 1,345 lines of guard — its own `METADATA_HOSTS`, its own -//! address predicates, its own WHATWG host extraction, its own resolve-then-pin — sitting beside a -//! near-identical 1,701-line definition in [`busbar_kernel_egress::trust::net`]. Two implementations -//! of one security control is not untidiness. It is the mechanism by which somebody hardens one and -//! the other keeps the hole, and it had already happened here twice: once inside this file (a -//! six-name metadata list shadowed privately inside `ssrf_blocked_host` while the module const the -//! DIALING path read held two), and once across the two files (the extraction was carried over -//! without the WHATWG leading/trailing trim, so `"https://10.99.99.99 "` read as a host that -//! matched no denylist entry while the connecting stack trimmed the space and dialled it). -//! -//! The first of those was the serious one, and it is worth naming exactly why: the address arm -//! cannot save a metadata endpoint that answers on a GLOBALLY ROUTABLE address. -//! `metadata.platformequinix.com` resolves and pins to a public literal, so -//! [`ip_is_cloud_metadata`] says nothing about it and [`judge_host_name`] — reading the short list — -//! said nothing either. NOTHING but the name arm can refuse that host, and the name arm was reading -//! the wrong list. -//! -//! So there is one definition now. It lives in the egress unit because #36 gives that unit "the -//! upstream leg — walk + swrr weighting + pre-dial guard; trust folded in", and because a guard -//! beside the walk is a guard every carrier reaches rather than one each transport must remember to -//! call. What is left here is a glob, so every in-core call site and all three plane crates keep -//! naming `busbar_kernel::net_guard::…` unchanged: this shim is what keeps a plane's reach pointed -//! at the kernel it already depends on instead of growing a new plane→unit edge. -//! -//! ## Why [`SystemResolver`] is here and the guard is not -//! -//! The guard is PURE — no I/O, no globals, one resolution taken through the [`Resolver`] seam — and -//! that purity is what lets it live in a crate whose entire dependency list is `busbar-contract` -//! plus `futures` (the spec holds the kernel-8 units to `⊆ {contract, plugin-sdk}`). A -//! `resolve_and_pin_async` that called `tokio::net::lookup_host` could not have moved there, and -//! for a while that one function was the stated reason the fork could not be collapsed. -//! -//! It was the wrong thing to move. A resolver is not a judgement; it is the thing the judgement is -//! applied to. So the async entry point is GONE rather than relocated, every caller funnels through -//! the one pure [`resolve_and_pin`], and the runtime-bound half — the actual `getaddrinfo` — is the -//! small binding below, in the crate that already owns tokio. The guard got smaller and the -//! constraint dissolved instead of being worked around. - -pub use busbar_kernel_egress::trust::net::*; - -use std::net::IpAddr; - -/// THE REAL RESOLVER: the system resolver, reached through tokio, bound to the guard's seam. -/// -/// This is what [`resolve_and_pin`] asks when the destination is a NAME rather than a literal, on -/// every path that used to call the deleted `resolve_and_pin_async`: the plane-host egress open, -/// the neutral full-duplex WS dial, the MCP dispatch pin and the OAuth2 client-ID-metadata fetch. -/// The ORDERING those paths depend on — structural refusals first so a hostile name never reaches a -/// resolver, then EXACTLY ONE lookup, then every answered address judged, then the pin — is stated -/// once, in [`resolve_and_pin`], and is not restated by any of them. -/// -/// Returns EVERY address the name answered with, de-duplicated but otherwise untouched and -/// unsorted, which is the same rule the A2A card fetch's own resolver states. Filtering or -/// re-ordering would quietly decide which address the guard gets to judge, and the guard's rule is -/// that it judges all of them; de-duplication is not filtering, because a name answering the same -/// address under both a v4 and a v6 query is ONE fact and nothing distinct is dropped. -/// -/// ## Why a thread of its own -/// -/// The seam is synchronous, so this runs `getaddrinfo` and waits. Not -/// `Handle::current().block_on(..)` and not `block_in_place`: both make assumptions about the -/// caller (that there IS a runtime, that it is multi-threaded) that a synchronous seam cannot make, -/// and every one of the four callers reaches it from a different posture — a foreign thread already -/// inside `Runtime::block_on`, a boxed future on a shared worker, a plain `async fn`. A thread with -/// its own current-thread runtime has no such precondition and cannot panic on the caller's behalf. -/// This is the shape `busbar-a2a`'s card-fetch resolver already uses in production, stated once -/// more here rather than reached across a plane boundary for it. -#[derive(Clone, Copy, Debug, Default)] -pub struct SystemResolver; - -impl Resolver for SystemResolver { - fn resolve(&self, host: &str) -> Result, String> { - // Port zero: this seam answers about ADDRESSES. The port belongs to the URL and is applied - // by the caller that built the pin, so asking the resolver about one would be asking a - // second question. - let target = format!("{host}:0"); - std::thread::scope(|s| { - s.spawn(|| { - let rt = tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - .map_err(|e| format!("name lookup: could not start a runtime: {e}"))?; - rt.block_on(async move { - let answered = tokio::net::lookup_host(target) - .await - .map_err(|e| e.to_string())?; - let mut out: Vec = Vec::new(); - for sa in answered { - let ip = sa.ip(); - if !out.contains(&ip) { - out.push(ip); - } - } - Ok(out) - }) - }) - .join() - .map_err(|_| "name lookup: the worker thread panicked".to_string())? - }) - } -} - -#[cfg(test)] -#[path = "tests/net_guard_tests.rs"] -mod tests; - -#[cfg(test)] -#[path = "tests/net_guard_fetch_tests.rs"] -mod fetch_tests; diff --git a/crates/busbar-kernel/src/oauth_as/mod.rs b/crates/busbar-kernel/src/oauth_as/mod.rs index 87e7edf423..adf42df160 100644 --- a/crates/busbar-kernel/src/oauth_as/mod.rs +++ b/crates/busbar-kernel/src/oauth_as/mod.rs @@ -67,13 +67,14 @@ //! feature this tree does not enable — the handover is a marked TODO in [`cimd`] tied to //! `chore/1.6.0-oauth-as-0.9.3`, and it replaces one call, not the seam. That //! fetch is an SSRF surface by construction — the URL is attacker-supplied — and it goes through -//! [`crate::net_guard`]'s resolve-then-pin guard rather than a second copy of it; a drifted copy -//! of exactly that guard was a live cloud-metadata bypass on the MCP plane. +//! the egress unit's (`busbar_kernel_egress::trust::net`) resolve-then-pin guard rather than a +//! second copy of it; a drifted copy of exactly that guard was a live cloud-metadata bypass on +//! the MCP plane. //! //! **It reaches for CORE, not for a plane.** An earlier draft of this note pointed at //! `a2a::fetch::fetch_card`, which would have made an authorization server's security control a //! dependency on the A2A plane's internals — and would have meant that deleting that plane broke -//! the AS. The guard now lives in `net_guard` with the knobs as parameters, so this path supplies +//! the AS. The guard now lives in the egress unit with the knobs as parameters, so this path supplies //! its OWN bounds — a client metadata document is a few kilobytes and the fetch is on an //! interactive authorization request, so 5 KB and 10 s, not the card fetch's 512 KB — and gets //! the same resolve-then-pin, the same unconditional cloud-metadata refusal and the same diff --git a/crates/busbar-kernel/src/observability.rs b/crates/busbar-kernel/src/observability.rs index 81ef0bda13..58f4aa0a25 100644 --- a/crates/busbar-kernel/src/observability.rs +++ b/crates/busbar-kernel/src/observability.rs @@ -14,8 +14,8 @@ // Here they are a defense-in-depth parity mirror (the webhook/OTLP URL is already // `url::Url::parse`-normalized, so the canonical `parse::()` path does the real // blocking); keeping the byte-identical atoms in one tested leaf stops the two guards drifting. -use crate::net_guard::{ - ipv4_is_internal as net_guard_ipv4_is_internal, ipv6_is_internal, is_alternate_ipv4_encoding, +use busbar_contract::net::{ + ipv4_is_internal as shared_ipv4_is_internal, ipv6_is_internal, is_alternate_ipv4_encoding, }; // 1.5.3 LIFT-OUT: the request-log webhook DELIVERY (the `WEBHOOK_URL`/`CLIENT`/ @@ -163,22 +163,22 @@ pub fn validate_webhook_url(url: Option) -> Result, Strin /// block DIFFERENT sets on the LOCALHOST family. /// /// The METADATA family is NOT a place the two may differ, and this is no longer a second copy: it -/// is [`crate::net_guard::METADATA_HOSTS`] itself — which is now the egress unit's list, reached -/// through the re-export shim, so there is exactly ONE metadata list in the tree and this guard +/// is [`busbar_contract::net::METADATA_HOSTS`] itself — the list the egress unit's guard and the +/// connector's guard read too, so there is exactly ONE metadata list in the tree and this guard /// reads it rather than a sibling of it. It used to be a private two-name list beside a six-name /// one, which is a telemetry guard that had never heard of four names the config guard refused. -use crate::net_guard::METADATA_HOSTS; +use busbar_contract::net::METADATA_HOSTS; /// True for an IPv4 literal busbar must not POST telemetry to. Shared by the V4 arm and the /// IPv4-mapped-IPv6 arm so the two stay identical. Covers loopback, link-local (incl. the /// `169.254.169.254` IMDS endpoint), RFC1918 private, RFC6598 CGNAT, unspecified, and broadcast. fn is_internal_v4(v4: &std::net::Ipv4Addr) -> bool { - // THE PREDICATE ITSELF LIVES IN `net_guard` and is called, not copied. It used to be spelled - // out here, and another SSRF guard elsewhere in the codebase would have been the THIRD copy of the + // THE PREDICATE ITSELF LIVES IN `busbar_contract::net` and is called, not copied. It used to + // be spelled out here, and another SSRF guard elsewhere in the codebase would have been the THIRD copy of the // same range list (Azure WireServer and OCI IMDS in particular sit on public addresses that every range // predicate misses, so a copy that forgets them looks correct). Hoisted rather than duplicated: // a contributor hardening one guard against a new range must not be able to miss the others. - net_guard_ipv4_is_internal(v4) + shared_ipv4_is_internal(v4) } /// True if the URL's host is an address busbar must not POST telemetry to: a literal loopback, @@ -223,8 +223,9 @@ fn host_is_internal(url: &url::Url) -> bool { return true; } - // Defense-in-depth parity mirror via the shared `net_guard::is_alternate_ipv4_encoding`, NOT the - // primary guard. For an http(s) URL the PRIMARY protection is `url::Url::parse`: http(s) + // Defense-in-depth parity mirror via the shared + // `busbar_contract::net::is_alternate_ipv4_encoding`, NOT the primary guard. For an + // http(s) URL the PRIMARY protection is `url::Url::parse`: http(s) // is a WHATWG "special scheme", so its host parser already canonicalizes every alternate // IPv4 encoding to a dotted-quad BEFORE we ever read `host_str()` — `2130706433` / // `0x7f000001` / `017700000001` / `127.1` / `0177.0.0.1` all arrive here as `127.0.0.1`, @@ -242,7 +243,7 @@ fn host_is_internal(url: &url::Url) -> bool { // THE V6 ARM IS THE SHARED PREDICATE, not a local transcription of it. It used to be // spelled out here — `is_loopback()`, then `to_ipv4()`, then unspecified / // unique-local / link-local — and the transcription had DROPPED `is_multicast()`, - // which `net_guard::ipv6_is_internal` has. `[ff02::1]` is the all-nodes group: it + // which `busbar_contract::net::ipv6_is_internal` has. `[ff02::1]` is the all-nodes group: it // reaches every host on the segment, and this guard admitted it while the predicate // it was written as a sibling of refused it. The order that arm depends on (loopback // first, embedded-v4 before the v6 masks) is documented once, on `ipv6_is_internal`. diff --git a/crates/busbar-kernel/src/plane/approvals.rs b/crates/busbar-kernel/src/plane/approvals.rs index 3f231124bd..e5c2aa7187 100644 --- a/crates/busbar-kernel/src/plane/approvals.rs +++ b/crates/busbar-kernel/src/plane/approvals.rs @@ -1,82 +1,21 @@ // SPDX-License-Identifier: Apache-2.0 // Copyright (C) 2026 Busbar Inc and contributors -//! THE SEALED `requestState` — minted by busbar, verified by busbar, and bound to the caller. +//! THE HOST'S HALF OF A PLANE'S CALLER-FACING APPROVAL: the spent-approval ledger that makes an +//! approval single-use, the short replay window a sealed approval lives for, and the nonce a plane +//! seals into one. //! -//! ## Why busbar MINTS state rather than relaying an upstream's -//! -//! `mrtr.mdx:232` makes it a `MUST` that a server reject `requestState` that fails verification, and -//! `mrtr.mdx:130` says the value is "meaningful only to the server" — a client "MUST NOT inspect, -//! parse, modify, or make any assumptions about its contents". Those two sentences together mean a -//! relayer cannot be conformant: an upstream's state is opaque to busbar by construction, so busbar -//! could only forward it blind, and the verification would then happen one hop away, at an upstream, -//! on behalf of a caller that upstream never authenticated. -//! -//! That is not merely inelegant. `mrtr.mdx:235` requires the state to bind **the authenticated -//! principal**, "rejecting state presented by a different principal" — and the only principal an -//! upstream can see is BUSBAR, identically for every one of busbar's callers. Relayed state -//! therefore binds to busbar rather than to the caller, so caller A's state is redeemable by caller -//! B and the upstream cannot tell the difference. Minting closes that by construction: the principal -//! in the sealed payload is the inbound key, so the cross-caller replay is a signature failure. -//! -//! ## What is sealed, and why each field is in there -//! -//! Every element `mrtr.mdx:234-237` lists as a `SHOULD`, plus two busbar can offer and the spec does -//! not ask for: -//! -//! | field | why | -//! |---|---| -//! | `principal` | `mrtr.mdx:235`. Caller A's state cannot be redeemed by caller B. | -//! | `method` + `capability` | `mrtr.mdx:237` — "an identifier for the originating request". State minted on `prompts/get` cannot be spent on `tools/call`, nor one tool's on another's. | -//! | `args_digest` | the rest of `mrtr.mdx:237` — "a digest of its salient parameters". | -//! | `issued_at` + `ttl` | `mrtr.mdx:236`, the short expiry. | -//! | `nonce` | two mints of an identical payload MUST differ, or the `multi-round` scenario's `r2.requestState !== r1.requestState` can never hold. A deterministic seal fails it. | -//! | `round` | THE LOOP BOUND. See below — this is the field without which the caller-facing loop has no cap at all. | -//! | `generation` | the catalogue generation the ask was minted under. State minted before an approval was revoked is refused after it — which the spec does not require and busbar can give for free, because dispatch already re-validates against a generation. | -//! -//! ## The round index is the bound, and it has to live IN the seal -//! -//! The upstream-facing loop ([`super::inputreq`]) is in-process, so its counter is a local variable -//! and the bound is per dispatch by construction. The caller-facing loop is not: it is spread across -//! INDEPENDENT requests over a stateless transport, with no session, because this revision has none. -//! A counter held in -//! memory between them would be a session by another name — the same objection -//! `client/jsonrpc.rs` already makes about `InputRequiredLoop`. So the count rides inside the -//! integrity-protected payload, where the caller can neither read it nor rewind it. Without that, -//! the cap is unenforceable: a caller replays round-1 state for ever and every replay is a fresh, -//! uncapped round. -//! -//! ## Integrity -//! -//! HMAC-SHA256 under a key DERIVED from `auth.signing_key` — the deployment's fleet-shared secret, -//! which is what lets one logical exchange span requests that different nodes may serve. Derived -//! rather than used raw, so this seal and the virtual-key token signer can never be confused for one -//! another even if one of them is later changed. Verification is `Mac::verify_slice`, which is -//! constant-time; the decode is strict base64url with no padding, because a decoder that tolerated -//! trailing bytes would accept the conformance suite's `+ "-TAMPERED"` mutation and the scenario -//! would go green while the property it names was absent. -//! -//! No signing key ⇒ no sealer ⇒ NO ASK. That is the fail-closed posture and it is deliberate: an ask -//! busbar cannot seal is an ask busbar cannot verify the answer to, and emitting one would be -//! inviting a retry it would have to trust. - -// D3 Phase-C: the ask-state seal PODs + crypto (`AskState`, `Rejected`, `Sealer`, mint/open, and -// the `DERIVE_DOMAIN`/`MAC_DOMAIN` and the ring HMAC-SHA256 they need) now live in the neutral -// substrate so a plane holds the seal without naming core. Re-exported here so every in-core call -// site (the sibling-crate caller that decides an ask, the tests) is unchanged. -// -// The neutral ask-state helpers `DEFAULT_TTL_SECS` (the short replay window) and `digest_arguments` -// (the salient-parameter digest) relocated to the substrate seal beside the PODs — pure `mrtr` data + -// SHA-256/`hex`, no core reach — and are re-exported here so `crate::plane::approvals::{DEFAULT_TTL_SECS, -// digest_arguments}` still resolves for the tests and for that sibling-crate caller. +//! The seal itself — the sealed payload, its MAC domains, mint/open and the request match — is the +//! plane's own, in the plane's crate, sealed over the host's `sign` (ARCHITECT Q-L3B-ASK). Core +//! holds no plane-named seal domain and no copy of the payload (audit K2-H3). /// THE SPENT-APPROVAL LEDGER — what makes an approval SINGLE-USE. /// /// ## What the seal could not do on its own /// -/// Everything else about this module is a statement the seal itself can carry: who it was minted -/// for, what request, which round, until when. Single use is the one property that cannot ride -/// inside the blob, because a caller presenting the identical blob a second time presents an +/// Everything else about an approval is a statement the plane's seal itself can carry: who it was +/// minted for, what request, which round, until when. Single use is the one property that cannot +/// ride inside the blob, because a caller presenting the identical blob a second time presents an /// identical, perfectly valid blob. The only thing that can tell the second presentation from the /// first is a RECORD THAT THE FIRST HAPPENED — and until this existed there was none, so an operator /// who gated a money-moving action behind a confirmation got confirm-once-execute-many. @@ -169,289 +108,27 @@ impl SpentTokenLedger { } } -#[cfg(test)] -#[path = "tests/askstate_tests.rs"] -mod askstate_tests; - // ONE APPROVAL, REDEEMED ONCE — across a restart and across a fleet. Judged through the served // door at the composition root (`crates/busbar/src/root/tests/door_steps.rs`, `spent_ledger`; // ARCHITECT Q-L3B-ASK): the plane door mints and redeems the sealed state over the host's `sign` // and spends it by the host's one-time `records.claim`, and only the root composes a door's // served path (P3 DEL-MCP). -// ==== merged from busbar-substrate (W4.b P2 engine drain) ==== -use base64::engine::general_purpose::URL_SAFE_NO_PAD; -use base64::Engine as _; -use ring::hmac; -use serde::{Deserialize, Serialize}; - /// The DEFAULT life of a sealed state. Short, per `mrtr.mdx:236`: a caller answering an elicitation /// is a human at a prompt, not a batch job, and every second of validity is a second of replay /// window. pub const DEFAULT_TTL_SECS: u64 = 300; -/// The digest of a request's salient parameters, per `mrtr.mdx:237`. -/// -/// Over the SERIALISED value rather than a field walk: the point is that the retry asks for the same -/// thing the ask was issued about, and any field of `arguments` can change what is being asked for. -/// `serde_json::Value`'s object representation is a `BTreeMap`, so the serialisation is key-ordered -/// and two equal values digest equally regardless of the order the caller sent them in. -pub fn digest_arguments(arguments: &serde_json::Value) -> String { - let bytes = serde_json::to_vec(arguments).unwrap_or_default(); - busbar_kernel_ledger::digest::sha256_hex(&bytes) -} - /// A fresh nonce. `getrandom` is the same fail-closed entropy source key secrets use; a failure is /// not survivable here, because a predictable nonce is a `multi-round` scenario that passes by /// accident and a replay window that is wider than it looks. /// /// The draw itself is governance's one 128-bit hex draw (`governance::generate_binding_generation`); -/// it is named here beside the seal so a plane mints a nonce through `plane::approvals::nonce`. +/// it is named here beside the ledger so a plane mints a nonce through `plane::approvals::nonce`. pub fn nonce() -> Result { crate::governance::generate_binding_generation() } -/// Domain separation for the derived key. Changing these BYTES invalidates every outstanding state, -/// which is the correct behaviour for a payload-format change — so the bytes are frozen. The plane -/// segment is written `\x6d` ('m') rather than the literal token: this is the ask-state plane's -/// domain, whose true home is the plane crate, reached there through the sealer seam. Until that -/// cross-crate move the neutral substrate carries the exact bytes without -/// spelling the plane in its source. -const DERIVE_DOMAIN: &[u8] = b"busbar/\x6dcp/askstate/derive/v1"; - -/// Domain separation for the MAC itself, prepended to the signed bytes. Belt and braces beside the -/// key derivation: even a deployment that somehow reused the raw key elsewhere cannot have one of -/// its blobs verify here. Same frozen bytes and same `\x6d` plane-token neutralisation as -/// [`DERIVE_DOMAIN`]. -const MAC_DOMAIN: &[u8] = b"busbar/\x6dcp/askstate/v1\0"; - -/// The sealed payload. Field names are short because this rides in a JSON body on every retry, and -/// the wire form is an implementation detail no client may parse (`mrtr.mdx:130`). -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -pub struct AskState { - /// The AUTHENTICATED PRINCIPAL — the inbound key's stable id. `mrtr.mdx:235`. - #[serde(rename = "p")] - pub principal: String, - /// The JSON-RPC method the ask was issued on: `tools/call` or `prompts/get`. - #[serde(rename = "m")] - pub method: String, - /// The NAMESPACED capability — `{server}_{tool}`, or the namespaced prompt name. - #[serde(rename = "c")] - pub capability: String, - /// A digest of the request's salient parameters. `mrtr.mdx:237`. - #[serde(rename = "d")] - pub args_digest: String, - /// The catalogue generation the ask was minted under. - #[serde(rename = "g")] - pub generation: u64, - /// Which round of the caller-facing loop this state ENDS. The retry that presents it is round - /// `round + 1`, and that is what the per-server cap is compared against. - #[serde(rename = "r")] - pub round: u32, - /// Fresh randomness, so two mints of an otherwise identical payload differ. - #[serde(rename = "n")] - pub nonce: String, - /// Unix seconds at mint. - #[serde(rename = "i")] - pub issued_at: u64, - /// Seconds of validity from `issued_at`. - #[serde(rename = "t")] - pub ttl_secs: u64, - /// The principal's ROOTS EPOCH at mint — present exactly when the exchange this state - /// continues includes a `roots/list` ask, absent otherwise so unrelated confirmations cannot - /// be invalidated by a roots announcement. See `crate::mcp::roots` for the mechanism and - /// [`Rejected::StaleRoots`] for the refusal a mismatch produces. - #[serde(rename = "e", default, skip_serializing_if = "Option::is_none")] - pub roots_epoch: Option, -} - -/// Why a presented `requestState` was refused. Every arm is a REFUSAL — there is no arm that means -/// "accept anyway" and none that means "re-prompt". `mrtr.mdx:232` and the conformance suite's -/// `tampered-state` comment agree on that: a complete result OR a fresh ask in answer to tampered -/// state both mean the server did not reject it. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum Rejected { - /// Not two base64url segments, or the payload is not the JSON this module writes. - Malformed, - /// The MAC did not verify. THE TAMPER ARM. - BadSignature, - /// Past `issued_at + ttl_secs`. - Expired, - /// Sealed for a different authenticated principal. The cross-caller replay `mrtr.mdx:235` names. - WrongPrincipal, - /// Sealed for a different method, capability, or argument set. - WrongRequest, - /// Sealed under a catalogue generation that is no longer live — an approval moved underneath it. - WrongGeneration, - /// ALREADY REDEEMED. Perfectly valid, for this caller, for this request, inside its window — and - /// already spent on the call it was minted to approve. See `SpentTokenLedger`. - AlreadySpent, - /// SEALED UNDER A ROOTS EPOCH THE CALLER ITSELF HAS SINCE MOVED. The state carried a roots - /// answer, and the caller sent `notifications/roots/list_changed` after it was minted — so - /// redeeming it would dispatch on roots the caller just disavowed. See `crate::mcp::roots`. - StaleRoots, -} - -impl Rejected { - /// The stable audit reason word. Named here so a new arm cannot land without one. - pub fn audit_reason(self) -> &'static str { - match self { - Rejected::Malformed => "state_malformed", - Rejected::BadSignature => "state_bad_signature", - Rejected::Expired => "state_expired", - Rejected::WrongPrincipal => "state_wrong_principal", - Rejected::WrongRequest => "state_wrong_request", - Rejected::WrongGeneration => "state_wrong_generation", - Rejected::AlreadySpent => "state_already_spent", - Rejected::StaleRoots => "state_stale_roots", - } - } -} - -impl std::fmt::Display for Rejected { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - // ONE message for every arm that a FORGER can reach, deliberately. The distinctions above - // exist for the audit row; telling a caller WHICH check its forged state failed is an - // oracle for forging a better one, and `mrtr.mdx:269-272` names the caller as the attacker - // for exactly this field. - // - // `StaleRoots` is the one deliberate exception, and the oracle argument is why it is safe: - // the epoch is compared only AFTER the MAC verified, so the only party that can ever read - // this message is the caller busbar genuinely minted the state for — and the trigger was - // that caller's own `notifications/roots/list_changed`. It learns nothing it did not - // announce itself, and it needs the distinct remedy: restart the exchange, answer with - // current roots. The unified message's remedy (there is none) would be wrong. - match self { - Rejected::StaleRoots => f.write_str( - "`requestState` was minted before you sent `notifications/roots/list_changed`, and \ - the exchange it continues includes a `roots/list` answer — which your notification \ - declared stale. Retry the request without `requestState` to restart the exchange \ - and answer with your current roots.", - ), - _ => f.write_str( - "`requestState` failed integrity verification and was refused. It is an opaque \ - value minted by this server for one caller, one request and a short window; it \ - cannot be modified, reused by another caller, replayed after it lapses, or \ - presented on a different request.", - ), - } - } -} - -/// THE SEAL. Holds a key derived from the deployment's signing key; its `Debug` never prints it. -#[derive(Clone)] -pub struct Sealer { - key: [u8; 32], -} - -impl std::fmt::Debug for Sealer { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("Sealer") - .field("key", &"") - .finish() - } -} - -impl Sealer { - /// Derive the ask-state key from the deployment's `auth.signing_key` secret. - /// - /// A DERIVATION rather than the raw bytes: the same secret is the ed25519 virtual-key signer, - /// and two unrelated uses of one secret should not be able to produce a blob the other accepts. - pub fn derive(signing_secret: &[u8; 32]) -> Self { - let out = hmac::sign( - &hmac::Key::new(hmac::HMAC_SHA256, signing_secret), - DERIVE_DOMAIN, - ); - let out = out.as_ref(); - let mut key = [0u8; 32]; - key.copy_from_slice(&out[..32]); - Self { key } - } - - /// MINT a sealed state for `state`. The returned string is what goes on the wire as - /// `requestState`, and it is opaque to every party but this one. - pub fn mint(&self, state: &AskState) -> String { - let payload = serde_json::to_vec(state).expect("AskState serialises"); - let encoded = URL_SAFE_NO_PAD.encode(&payload); - let tag = self.tag(encoded.as_bytes()); - format!("{encoded}.{}", URL_SAFE_NO_PAD.encode(tag)) - } - - /// OPEN a presented state: decode, verify the MAC, check the expiry. Nothing here consults the - /// request — that is [`AskState::matches`], kept separate so the crypto has no way to be made - /// context-dependent by a later edit. - pub fn open(&self, blob: &str, now: u64) -> Result { - let (encoded, sig) = blob.split_once('.').ok_or(Rejected::Malformed)?; - // STRICT decode. `-` and `_` are base64url characters, so the conformance suite's - // `+ "-TAMPERED"` decodes rather than erroring — and lands on a tag of the wrong length and - // the wrong bytes, which is why the length check and the MAC check are both here and why - // neither may be relaxed into "starts with". - let tag = URL_SAFE_NO_PAD - .decode(sig.as_bytes()) - .map_err(|_| Rejected::Malformed)?; - let mut signed = Vec::with_capacity(MAC_DOMAIN.len() + encoded.len()); - signed.extend_from_slice(MAC_DOMAIN); - signed.extend_from_slice(encoded.as_bytes()); - // CONSTANT TIME, and it also rejects a tag of the wrong length rather than comparing a - // prefix. A `==` on `Vec` would be neither. - hmac::verify(&self.mac_key(), &signed, &tag).map_err(|_| Rejected::BadSignature)?; - - let payload = URL_SAFE_NO_PAD - .decode(encoded.as_bytes()) - .map_err(|_| Rejected::Malformed)?; - let state: AskState = serde_json::from_slice(&payload).map_err(|_| Rejected::Malformed)?; - // The expiry is checked here rather than in `matches` because it is a property of the state - // alone, and because a state that has lapsed must be refused even by a caller that gets - // every other field right. - if now > state.issued_at.saturating_add(state.ttl_secs) { - return Err(Rejected::Expired); - } - Ok(state) - } - - /// The MAC over the domain tag and the encoded payload. - fn tag(&self, encoded: &[u8]) -> [u8; 32] { - let mut mac = hmac::Context::with_key(&self.mac_key()); - mac.update(MAC_DOMAIN); - mac.update(encoded); - let mut tag = [0u8; 32]; - tag.copy_from_slice(&mac.sign().as_ref()[..32]); - tag - } - - /// The HMAC-SHA256 key (ring, the one crypto backend) over the derived bytes. - fn mac_key(&self) -> hmac::Key { - hmac::Key::new(hmac::HMAC_SHA256, &self.key) - } -} - -impl AskState { - /// Does this opened state belong to THE REQUEST NOW IN HAND? - /// - /// Separate from [`Sealer::open`] on purpose: `open` proves busbar minted the blob, and this - /// proves busbar minted it FOR THIS. A seal that only proved the first would let any caller - /// replay its own valid state onto a different tool, a different argument set, or a catalogue - /// generation under which the approval has since been withdrawn. - pub fn matches( - &self, - principal: &str, - method: &str, - capability: &str, - args_digest: &str, - generation: u64, - ) -> Result<(), Rejected> { - if self.principal != principal { - return Err(Rejected::WrongPrincipal); - } - if self.method != method || self.capability != capability { - return Err(Rejected::WrongRequest); - } - if self.args_digest != args_digest { - return Err(Rejected::WrongRequest); - } - if self.generation != generation { - return Err(Rejected::WrongGeneration); - } - Ok(()) - } -} +#[cfg(test)] +#[path = "tests/approvals_tests.rs"] +mod approvals_tests; diff --git a/crates/busbar-kernel/src/plane/tests/approvals_tests.rs b/crates/busbar-kernel/src/plane/tests/approvals_tests.rs new file mode 100644 index 0000000000..de10ce581b --- /dev/null +++ b/crates/busbar-kernel/src/plane/tests/approvals_tests.rs @@ -0,0 +1,50 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (C) 2026 Busbar Inc and contributors + +//! CORE HOLDS NO PLANE'S SEAL (audit K2-H3). The caller-facing ask-state seal is the plane's own, +//! sealed over the host's `sign`; core keeps only the spent-approval ledger, the replay window and +//! the nonce. RED before the deletion: the kernel carried a dead copy of the seal, its payload and +//! its two MAC domains, spelled with the plane token escaped. + +use std::path::Path; + +/// Every `.rs` file under `dir`. +fn sources(dir: &Path, out: &mut Vec) { + for entry in std::fs::read_dir(dir) + .expect("a source directory") + .flatten() + { + let path = entry.path(); + if path.is_dir() { + sources(&path, out); + } else if path.extension().is_some_and(|e| e == "rs") { + out.push(path); + } + } +} + +#[test] +fn the_kernel_holds_no_plane_named_seal_domain() { + // The needles are built here, so this file does not carry them: the escaped plane token the + // seal domains were spelled with, and the domains' own path segment. + let escaped = format!("busbar/\\x{:02x}", b'm'); + let segment = ["ask", "state/"].concat(); + let mut files = Vec::new(); + sources( + &Path::new(env!("CARGO_MANIFEST_DIR")).join("src"), + &mut files, + ); + assert!(!files.is_empty(), "the kernel's sources are read"); + let carriers: Vec = files + .iter() + .filter(|f| { + let text = std::fs::read_to_string(f).unwrap_or_default(); + text.contains(&escaped) || text.contains(&segment) + }) + .map(|f| f.display().to_string()) + .collect(); + assert!( + carriers.is_empty(), + "the kernel carries a plane's seal domain: {carriers:?}" + ); +} diff --git a/crates/busbar-kernel/src/plane/tests/askstate_tests.rs b/crates/busbar-kernel/src/plane/tests/askstate_tests.rs deleted file mode 100644 index 6dd3b54471..0000000000 --- a/crates/busbar-kernel/src/plane/tests/askstate_tests.rs +++ /dev/null @@ -1,322 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// Copyright (C) 2026 Busbar Inc and contributors - -//! THE SEAL, exercised against the mutations that actually happen. -//! -//! The conformance suite's `tampered-state` scenario does exactly one thing to the state busbar -//! minted: it appends the literal `-TAMPERED`. That mutation is the FIRST test below and it is -//! deliberately not generalised away, because `-` is a valid base64url character: a decoder -//! configured to tolerate trailing input would accept it, the scenario would go green, and the -//! property the scenario is named after would be absent. A test written only against a random blob -//! would not have caught that. -//! -//! Every other test here is a field of the payload changed one at a time, which is the only way to -//! show that each field is LOAD-BEARING rather than merely present. - -use super::*; -// The seal crypto lives in `busbar_kernel::plane::approvals`, so the base64url encoder these -// tampering tests build forged blobs with is imported directly rather than inherited from the parent -// module's private `use`. -use base64::engine::general_purpose::URL_SAFE_NO_PAD; -use base64::Engine as _; - -const KEY: [u8; 32] = [7u8; 32]; -const NOW: u64 = 1_700_000_000; - -fn sealer() -> Sealer { - Sealer::derive(&KEY) -} - -fn state() -> AskState { - AskState { - principal: "vk_caller_a".to_string(), - method: "tools/call".to_string(), - capability: "test_thing".to_string(), - args_digest: digest_arguments(&serde_json::json!({ "path": "/etc/hosts" })), - generation: 4, - round: 0, - nonce: nonce().expect("entropy"), - issued_at: NOW, - ttl_secs: DEFAULT_TTL_SECS, - // The battery's baseline state is NOT roots-bearing; the epoch's own behaviour is judged - // end to end in `roots_changed_tests.rs`. - roots_epoch: None, - } -} - -/// The happy path, and the control without which every refusal below is equally consistent with a -/// seal that refuses everything. -#[test] -fn a_state_busbar_minted_opens_and_matches_the_request_it_was_minted_for() { - let s = state(); - let blob = sealer().mint(&s); - let opened = sealer().open(&blob, NOW).expect("busbar's own state opens"); - assert_eq!(opened, s); - opened - .matches( - "vk_caller_a", - "tools/call", - "test_thing", - &digest_arguments(&serde_json::json!({ "path": "/etc/hosts" })), - 4, - ) - .expect("the request it was minted for"); -} - -/// THE SUITE'S EXACT MUTATION. `sep-2322-reject-tampered-state` sends -/// `r1Result.requestState + '-TAMPERED'` and requires a JSON-RPC error. -#[test] -fn the_conformance_suites_exact_tamper_is_refused() { - let blob = sealer().mint(&state()); - assert_eq!( - sealer().open(&format!("{blob}-TAMPERED"), NOW), - Err(Rejected::BadSignature), - "`-TAMPERED` is all base64url characters, so a lenient decoder accepts it" - ); -} - -/// And the mutation in the other segment, which a suffix-only test would miss: rewriting the PAYLOAD -/// while keeping a valid-looking tag. -#[test] -fn a_rewritten_payload_is_refused_even_though_the_tag_is_untouched() { - let mut s = state(); - let honest = sealer().mint(&s); - let (_, tag) = honest.split_once('.').expect("two segments"); - // The caller promotes itself: same everything, different principal. - s.principal = "vk_caller_b".to_string(); - let forged_payload = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&s).expect("serialises")); - assert_eq!( - sealer().open(&format!("{forged_payload}.{tag}"), NOW), - Err(Rejected::BadSignature) - ); -} - -/// Every single byte of the tag matters. A comparison that stopped at a prefix, or one that accepted -/// a short tag, would pass the two tests above and fail this one. -#[test] -fn a_tag_that_is_short_or_altered_in_any_single_position_is_refused() { - let blob = sealer().mint(&state()); - let (payload, tag) = blob.split_once('.').expect("two segments"); - // A truncated tag is REFUSED, and which refusal it is depends on where it dies: a base64url - // segment two characters short is not a whole number of bytes, so it fails the decode before it - // reaches the MAC. The property under test is that it does not verify AS A PREFIX of the honest - // tag, and both arms are that; pinning one would be asserting the order of two checks rather - // than the thing they jointly guarantee. - assert!( - matches!( - sealer().open(&format!("{payload}.{}", &tag[..tag.len() - 2]), NOW), - Err(Rejected::BadSignature) | Err(Rejected::Malformed) - ), - "a truncated tag must not verify as a prefix" - ); - // And one that IS a whole number of bytes, just fewer of them: this one does reach the MAC, so - // it is the case that proves the comparison rejects on length rather than comparing a prefix. - let short = URL_SAFE_NO_PAD.encode([0u8; 16]); - assert_eq!( - sealer().open(&format!("{payload}.{short}"), NOW), - Err(Rejected::BadSignature) - ); - let mut altered: Vec = tag.chars().collect(); - altered[0] = if altered[0] == 'A' { 'B' } else { 'A' }; - let altered: String = altered.into_iter().collect(); - assert_eq!( - sealer().open(&format!("{payload}.{altered}"), NOW), - Err(Rejected::BadSignature) - ); -} - -/// A DIFFERENT DEPLOYMENT'S key does not open this deployment's state. The fleet shares one signing -/// key by design; two fleets do not. -#[test] -fn another_deployments_seal_does_not_open_here() { - let blob = Sealer::derive(&[9u8; 32]).mint(&state()); - assert_eq!(sealer().open(&blob, NOW), Err(Rejected::BadSignature)); -} - -/// `mrtr.mdx:236` — the short expiry, rejected after it lapses. Checked at the boundary in both -/// directions, because an off-by-one here is a replay window that is one TTL wider than declared. -#[test] -fn state_is_refused_the_moment_it_lapses() { - let s = state(); - let blob = sealer().mint(&s); - assert!( - sealer().open(&blob, NOW + DEFAULT_TTL_SECS).is_ok(), - "still valid at exactly the expiry instant" - ); - assert_eq!( - sealer().open(&blob, NOW + DEFAULT_TTL_SECS + 1), - Err(Rejected::Expired) - ); -} - -/// THE CROSS-CALLER REPLAY `mrtr.mdx:235` NAMES. Caller A's state, presented by caller B, with a -/// perfectly valid signature, must not be redeemed. This is the property a relayer cannot have at -/// all, because the only principal an upstream can bind to is busbar. -#[test] -fn caller_a_state_cannot_be_redeemed_by_caller_b() { - let blob = sealer().mint(&state()); - let opened = sealer().open(&blob, NOW).expect("the signature is honest"); - assert_eq!( - opened.matches( - "vk_caller_b", - "tools/call", - "test_thing", - &digest_arguments(&serde_json::json!({ "path": "/etc/hosts" })), - 4, - ), - Err(Rejected::WrongPrincipal) - ); -} - -/// `mrtr.mdx:237` — the originating request. Each of the three components is changed on its own, so -/// each is shown to be load-bearing rather than carried. -#[test] -fn state_cannot_be_spent_on_a_different_request_than_it_was_minted_for() { - let blob = sealer().mint(&state()); - let opened = sealer().open(&blob, NOW).expect("honest"); - let honest_digest = digest_arguments(&serde_json::json!({ "path": "/etc/hosts" })); - - // A different METHOD: an ask issued on `tools/call` spent on `prompts/get`. - assert_eq!( - opened.matches( - "vk_caller_a", - "prompts/get", - "test_thing", - &honest_digest, - 4 - ), - Err(Rejected::WrongRequest) - ); - // A different CAPABILITY: one capability's ask spent on another capability. - assert_eq!( - opened.matches("vk_caller_a", "tools/call", "test_other", &honest_digest, 4), - Err(Rejected::WrongRequest) - ); - // Different ARGUMENTS: the ask was about `/etc/hosts`; the retry is about the private key. - assert_eq!( - opened.matches( - "vk_caller_a", - "tools/call", - "test_thing", - &digest_arguments(&serde_json::json!({ "path": "/root/.ssh/id_ed25519" })), - 4, - ), - Err(Rejected::WrongRequest) - ); -} - -/// The binding the specification does NOT ask for and busbar can give anyway: state minted under an -/// approval that has since been withdrawn does not complete the call the operator withdrew. -#[test] -fn state_minted_under_a_stale_catalogue_generation_is_refused() { - let blob = sealer().mint(&state()); - let opened = sealer().open(&blob, NOW).expect("honest"); - assert_eq!( - opened.matches( - "vk_caller_a", - "tools/call", - "test_thing", - &digest_arguments(&serde_json::json!({ "path": "/etc/hosts" })), - 5, - ), - Err(Rejected::WrongGeneration) - ); -} - -/// THE `multi-round` REQUIREMENT, which is a requirement about the SEAL and not about the loop: -/// `sep-2322-multi-round-r2` fails unless `r2.requestState !== r1.requestState`. A deterministic -/// seal over an identical payload produces an identical string and can never satisfy it. -#[test] -fn two_mints_of_the_same_logical_ask_differ() { - let s = sealer(); - let mut a = state(); - a.nonce = nonce().expect("entropy"); - let mut b = a.clone(); - b.nonce = nonce().expect("entropy"); - assert_ne!(a.nonce, b.nonce, "the nonce is what makes the seals differ"); - assert_ne!(s.mint(&a), s.mint(&b)); - // And the round index moves too, so even a nonce collision would not produce equal states. - let mut r1 = a.clone(); - r1.round = 1; - assert_ne!(s.mint(&a), s.mint(&r1)); -} - -/// Malformed input is refused rather than panicking. A `requestState` is attacker-controlled by -/// definition (`mrtr.mdx:232`), so every one of these arrives eventually. -#[test] -fn malformed_state_is_refused_and_never_panics() { - for blob in [ - "", - "no-dot-at-all", - ".", - "..", - "not!base64.not!base64", - "AAAA.AAAA", - // A valid payload with no tag at all. - "eyJwIjoieCJ9.", - ] { - let got = sealer().open(blob, NOW); - assert!( - matches!(got, Err(Rejected::Malformed) | Err(Rejected::BadSignature)), - "`{blob}` must be refused, got {got:?}" - ); - } -} - -/// The digest is over the VALUE, not the byte order the caller happened to send. Two equal argument -/// objects written in different key orders are the same request and must digest the same, or a -/// caller re-serialising its own retry would be told its state was for a different request. -#[test] -fn the_argument_digest_is_key_order_independent_and_value_sensitive() { - let a: serde_json::Value = serde_json::from_str(r#"{"a":1,"b":2}"#).expect("json"); - let b: serde_json::Value = serde_json::from_str(r#"{"b":2,"a":1}"#).expect("json"); - assert_eq!(digest_arguments(&a), digest_arguments(&b)); - let c: serde_json::Value = serde_json::from_str(r#"{"a":1,"b":3}"#).expect("json"); - assert_ne!(digest_arguments(&a), digest_arguments(&c)); -} - -/// Every refusal arm has an audit word, and they are DISTINCT — a shared word would make the audit -/// trail unable to tell a lapsed state from a forged one, which are very different events. -#[test] -fn every_refusal_arm_has_its_own_audit_word() { - let arms = [ - Rejected::Malformed, - Rejected::BadSignature, - Rejected::Expired, - Rejected::WrongPrincipal, - Rejected::WrongRequest, - Rejected::WrongGeneration, - Rejected::AlreadySpent, - ]; - let words: std::collections::BTreeSet<&str> = arms.iter().map(|r| r.audit_reason()).collect(); - assert_eq!(words.len(), arms.len(), "audit words must be distinct"); -} - -/// The message a CALLER sees carries no oracle. `mrtr.mdx:269-272` names the client as the attacker -/// on this field, so "which check did I fail" is exactly what must not be answered. -#[test] -fn the_caller_facing_message_does_not_say_which_check_failed() { - let arms = [ - Rejected::BadSignature, - Rejected::Expired, - Rejected::WrongPrincipal, - Rejected::WrongRequest, - Rejected::WrongGeneration, - // An approval already spent renders IDENTICALLY. It is the one arm where a distinct message - // would be actively dangerous: it would tell a caller that the state it just presented WAS - // valid, which is more than any other refusal admits. - Rejected::AlreadySpent, - ]; - let messages: std::collections::BTreeSet = arms.iter().map(|r| r.to_string()).collect(); - assert_eq!( - messages.len(), - 1, - "every arm must render the SAME caller-facing message: {messages:?}" - ); - for r in arms { - assert!( - !r.to_string().contains(r.audit_reason()), - "the audit word must not leak into the caller-facing message" - ); - } -} diff --git a/crates/busbar-kernel/src/plane/tests/demotion_tests.rs b/crates/busbar-kernel/src/plane/tests/demotion_tests.rs index d8d22dcdad..581dd39fb6 100644 --- a/crates/busbar-kernel/src/plane/tests/demotion_tests.rs +++ b/crates/busbar-kernel/src/plane/tests/demotion_tests.rs @@ -223,9 +223,10 @@ fn with_no_durable_sink_a_demotion_is_recorded_nowhere() { } /// AND THE RAM DEFAULT REALLY IS RAM — which is a statement about the PROCESS, not about the write. -/// Within one process `busbar-store-memory` is a real store and reads a demotion back; what it loses -/// is everything the moment the process ends, and a restart is modelled here the only way a RAM -/// restart can be, by opening a new one. That is the documented `store: memory` contract. +/// Within one process the RAM store (here its kernel double) is a real store and reads a demotion +/// back; what it loses is everything the moment the process ends, and a restart is modelled here the +/// only way a RAM restart can be, by opening a new one. That is the documented `store: memory` +/// contract. /// /// The "a write that reaches nothing reads back as nothing" control lives in the no-sink case above, /// where it belongs: the engine learns durability by READING BACK and never from a write's return diff --git a/crates/busbar-kernel/src/plane_driver/mod.rs b/crates/busbar-kernel/src/plane_driver/mod.rs index f6fa8907d1..9f162a26e1 100644 --- a/crates/busbar-kernel/src/plane_driver/mod.rs +++ b/crates/busbar-kernel/src/plane_driver/mod.rs @@ -581,6 +581,10 @@ pub trait DriverSteps: Units { /// `dialect` (the one `arrive` answered): the previous release's translation counter counts an /// attempt whose far end speaks another dialect. fn attempting(&self, _ctx: &UnitCtx, _dialect: u32, _provider: &str) {} + /// The plane's answer for the unit, read in its `dialect`, carried a USAGE-TAP FAULT of + /// `reason` (`abi::plane::OnPieceOut::tap_fault`): the steps count it, once per answer that + /// carries it (`handlers::usage_tap_fault`). + fn usage_tap_fault(&self, _ctx: &UnitCtx, _dialect: u32, _reason: &'static str) {} /// THE STEPS' OWN WORDS for a refusal they decided with `reason`, handed to the plane's /// `refusal` as [`busbar_contract::abi::plane::RefusalIn::text`] (as a limit names the bucket /// that blocked); `None` = the reason's word. A `ROUTE_SCOPE` unit refused because several diff --git a/crates/busbar-kernel/src/plane_driver/route.rs b/crates/busbar-kernel/src/plane_driver/route.rs index b1093afbb7..0fa783e274 100644 --- a/crates/busbar-kernel/src/plane_driver/route.rs +++ b/crates/busbar-kernel/src/plane_driver/route.rs @@ -546,6 +546,7 @@ struct Answer { final_status: u32, final_message: Span, final_details: Span, + tap_fault: u8, } impl Answer { @@ -570,8 +571,15 @@ impl Answer { final_status: o.final_status, final_message: o.final_message, final_details: o.final_details, + tap_fault: o.tap_fault, } } + + /// The usage-tap fault this answer carries, as the reason the kernel counts it under; `None` + /// only for an answer that carries none. A byte outside the vocabulary is `unknown`, counted. + fn usage_tap_fault(&self) -> Option<&'static str> { + busbar_contract::codec::UsageTapFault::reason_of(self.tap_fault) + } } /// Where a piece's emitted bytes go. @@ -891,12 +899,17 @@ async fn until(left: Option) { pub trait AttemptSteps { /// An attempt starts on a member of `provider`, the unit's request read in `dialect`. fn attempting(&self, _ctx: &UnitCtx, _dialect: u32, _provider: &str) {} + /// The plane's answer carried a usage-tap fault of `reason` for the unit read in `dialect`. + fn usage_tap_fault(&self, _ctx: &UnitCtx, _dialect: u32, _reason: &'static str) {} } impl AttemptSteps for S { fn attempting(&self, ctx: &UnitCtx, dialect: u32, provider: &str) { super::DriverSteps::attempting(self, ctx, dialect, provider); } + fn usage_tap_fault(&self, ctx: &UnitCtx, dialect: u32, reason: &'static str) { + super::DriverSteps::usage_tap_fault(self, ctx, dialect, reason); + } } impl AttemptSteps for () {} @@ -1128,6 +1141,12 @@ impl super::PlaneUnits<'_, S, F, C> { drop(st); self.driver.money.checkpoint(run.ctx, units) }; + // THE USAGE-TAP FAULT the plane returned beside its units (row 155): counted here, once + // per answer that carries it (the plane writes it on one window of the answer). + if let Some(reason) = out.usage_tap_fault() { + self.steps + .usage_tap_fault(run.ctx, run.bufs.dialect, reason); + } // THE UNIT'S LEDGER LANE, where the answer names one (SEAM-L(j)): the money steps // lane the unit by it from here. A lane that is not UTF-8 is a plane fault. let lane = run.bufs.arena(out.lane); diff --git a/crates/busbar-kernel/src/plane_driver/tests/money_tests.rs b/crates/busbar-kernel/src/plane_driver/tests/money_tests.rs index 559000922e..27f2afb39f 100644 --- a/crates/busbar-kernel/src/plane_driver/tests/money_tests.rs +++ b/crates/busbar-kernel/src/plane_driver/tests/money_tests.rs @@ -438,6 +438,69 @@ fn a_plane_named_lane_is_the_units_ledger_lane_not_its_route_entry() { ); } +/// THE PLANE'S DECLARED CLASSES REACH ITS ROW (#71; /status row 123, the rigs' `h2-class-price`): a +/// served unit of a plane that declares a count class and a byte class, reporting one call and the +/// answer's length beside its fee unit, meters ONE request on the plane's row `(lane, plane)` and +/// that row carries both counts, which the admin usage read prices (`classes..{count,cost}`). +/// The fee unit says only whether the fee was incurred: it is never a counted class. RED: counts +/// lost on the way to the row read 0 under the declared classes and only the flat fee is charged. +#[test] +fn a_plane_units_declared_class_counts_reach_its_metering_row() { + const CALLS: u32 = 0; + const BYTES: u32 = 1; + let r = rig_with(None, 0, ExhaustionMode::FinishUnit, plane_fees()); + r.money.open( + UnitKey::new(1), + UnitMoney { + model: "tp\u{1f}srv".into(), + classes: Arc::from(vec![ + "calls".to_string(), + "bytes".to_string(), + busbar_contract::plane::PER_REQUEST.to_string(), + ]), + ..r.unit.clone() + }, + ); + let report = [ + fee_reported(1), + UnitCount { + class: CALLS, + source: UNITS_REPORTED, + amount: 1, + }, + UnitCount { + class: BYTES, + source: UNITS_REPORTED, + amount: 37, + }, + ]; + let _ = r.money.checkpoint(&ctx(1), &report); + r.money.laned(&ctx(1), "srv_read"); + r.money.settle_end(UnitKey::new(1), 200); + let rows = metering_rows(&r); + let counts: Vec<_> = rows + .iter() + .map(|row| { + ( + row.model.as_str(), + row.provider.as_str(), + row.requests, + row.usage_units.clone(), + ) + }) + .collect(); + assert_eq!( + counts, + vec![( + "srv_read", + "tp", + 1, + BTreeMap::from([("bytes".to_string(), 37), ("calls".to_string(), 1)]), + )], + "one request on the plane's row, carrying every declared class it reported and no fee unit" + ); +} + /// A unit whose plane names no lane is laned by its route entry, as before (the serving member's). #[test] fn a_unit_naming_no_lane_keeps_its_route_entry() { diff --git a/crates/busbar-kernel/src/plane_driver/tests/route_tests.rs b/crates/busbar-kernel/src/plane_driver/tests/route_tests.rs index f5a915e040..2b6b23065c 100644 --- a/crates/busbar-kernel/src/plane_driver/tests/route_tests.rs +++ b/crates/busbar-kernel/src/plane_driver/tests/route_tests.rs @@ -83,6 +83,31 @@ fn only_the_answers_first_piece_lends_the_kept_head() { assert!(i.head_fields.is_null() && i.head_fields_len == 0); } +/// THE USAGE-TAP FAULT IS READ OFF THE ANSWER (row 155): a plane's answer carrying a usage-tap +/// fault beside its units reads as the reason the kernel counts it under, and one carrying none +/// reads as nothing to count. A byte outside the vocabulary reads as `unknown`: counted, never +/// ignored and never read as none. The steps count each answer's fault once, and a plane writes it +/// on one window of its answer. +#[test] +fn an_answers_usage_tap_fault_reads_as_the_reason_the_kernel_counts() { + use busbar_contract::abi::plane::{ + OnPieceOut, TAP_FAULT_BAD_JSON, TAP_FAULT_DECODE, TAP_FAULT_NONE, + TAP_FAULT_UNKNOWN_PROTOCOL, + }; + let mut o: OnPieceOut = blank_out(); + for (code, reason) in [ + (TAP_FAULT_NONE, None), + (TAP_FAULT_DECODE, Some("decode")), + (TAP_FAULT_BAD_JSON, Some("bad_json")), + (TAP_FAULT_UNKNOWN_PROTOCOL, Some("unknown_protocol")), + (TAP_FAULT_UNKNOWN_PROTOCOL + 1, Some("unknown")), + (u8::MAX, Some("unknown")), + ] { + o.tap_fault = code; + assert_eq!(Answer::of(&o).usage_tap_fault(), reason, "{code}"); + } +} + #[test] fn every_piece_says_whether_the_attempts_member_relays_the_callers_credential() { let mut b = bufs(0); diff --git a/crates/busbar-kernel/src/plane_host/egress.rs b/crates/busbar-kernel/src/plane_host/egress.rs index 89237b65f2..70b7841c5f 100644 --- a/crates/busbar-kernel/src/plane_host/egress.rs +++ b/crates/busbar-kernel/src/plane_host/egress.rs @@ -6,7 +6,7 @@ //! The host OWNS every outbound byte. A plane never holds a client, a socket, a resolver, or a //! credential in plaintext; it holds an opaque [`EgressId`] and asks the host to open, pump, write //! and close. That is the whole point of routing egress through this seam: the SSRF resolve-then-pin -//! ([`crate::net_guard`]), the SPKI observation, the mTLS client-identity, the breaker and the meter +//! ([`busbar_kernel_egress::trust::net`]), the SPKI observation, the mTLS client-identity, the breaker and the meter //! are all ONE chokepoint the host controls, applied identically whatever the plane is dispatching. //! //! ## What is wired here, and what is a faithful Phase-2 note @@ -15,7 +15,7 @@ //! built from the [`EgressDesc`] outbound tail (the `verb`, the packed header set, and the one-shot //! request `body`), the credential is INJECTED host-side (the resolved credential the plane named by //! ref, never plaintext the plane held — see [`inject_credential`]), then resolve-then-pin over -//! [`crate::net_guard::resolve_and_pin`], a per-hop PINNED client (the a2a lesson — a pooled +//! [`busbar_kernel_egress::trust::net::resolve_and_pin`], a per-hop PINNED client (the a2a lesson — a pooled //! client re-resolves and reopens the DNS-rebind window, so a governed hop pins the address and //! refuses a second lookup), the post-connect observed peer identity handed back in the //! [`EgressHead`], a background streaming task that pumps `resp.chunk().await` into a bounded @@ -82,7 +82,7 @@ const CHUNK_CHANNEL_DEPTH: usize = 16; // wired. The guard stays FAIL-CLOSED by default: a scope of 0 reaches neither. /// This allowlist scope permits private / loopback / CGNAT addresses (cloud-metadata stays refused — -/// that is the guard, not a policy the scope can speak for; see [`crate::net_guard`]). +/// that is the guard, not a policy the scope can speak for; see [`busbar_kernel_egress::trust::net`]). const SCOPE_ALLOW_PRIVATE: u32 = 1 << 0; /// This allowlist scope permits a plaintext `http://` endpoint. const SCOPE_ALLOW_PLAINTEXT: u32 = 1 << 1; @@ -274,11 +274,11 @@ fn observed_identity(resp: &http::Response) -> Vec { .unwrap_or_default() } -/// Build the [`crate::net_guard::GuardPolicy`] for a hop from the host's allowlist-scope stance. Only +/// Build the [`busbar_kernel_egress::trust::net::GuardPolicy`] for a hop from the host's allowlist-scope stance. Only /// the private/plaintext arms are the scope's to relax; the cloud-metadata and obfuscated-encoding /// refusals are the guard and no scope can speak for them. -fn guard_policy(scope: u32) -> crate::net_guard::GuardPolicy { - crate::net_guard::GuardPolicy { +fn guard_policy(scope: u32) -> busbar_kernel_egress::trust::net::GuardPolicy { + busbar_kernel_egress::trust::net::GuardPolicy { allow_private: scope & SCOPE_ALLOW_PRIVATE != 0, allow_plaintext: scope & SCOPE_ALLOW_PLAINTEXT != 0, // Unused by the resolve-then-pin path (it judges names and addresses); the streaming read is @@ -579,7 +579,9 @@ impl OperatorDestinations { fn walk(v: &serde_yaml::Value, out: &mut Vec<(bool, String, u16)>) { match v { serde_yaml::Value::String(s) => { - if let Ok((https, host, port, _)) = crate::net_guard::split_url(s.trim()) { + if let Ok((https, host, port, _)) = + busbar_kernel_egress::trust::net::split_url(s.trim()) + { out.push((https, host.to_ascii_lowercase(), port)); } } @@ -609,7 +611,7 @@ impl OperatorDestinations { { return 0; } - let local = crate::net_guard::host_is_private_or_loopback(&host); + let local = busbar_kernel_egress::trust::net::host_is_private_or_loopback(&host); match (https, local) { (_, true) => SCOPE_ALLOW_PRIVATE | SCOPE_ALLOW_PLAINTEXT, (true, false) if !pinned => SCOPE_ALLOW_PRIVATE, @@ -630,7 +632,7 @@ fn host_scope(state: &HostState, d: &EgressDesc) -> u32 { // SAFETY: `(target_ptr, target_len)` is a live borrowed range for the call (ABI discipline). let bytes = unsafe { std::slice::from_raw_parts(d.target_ptr, d.target_len) }; let Ok(Ok((https, host, port, _))) = - std::str::from_utf8(bytes).map(crate::net_guard::split_url) + std::str::from_utf8(bytes).map(busbar_kernel_egress::trust::net::split_url) else { return 0; }; @@ -683,7 +685,7 @@ fn open_http( // Strict-recognise the URL (structural: scheme/host/port — no resolution), so a malformed / // non-http(s) target fails fast without spawning a thread. The host uses the URL HOST for SNI / // certificate-name / mTLS whether or not it also resolves it. - let (https, host_name, port, _path) = match crate::net_guard::split_url(&url) { + let (https, host_name, port, _path) = match busbar_kernel_egress::trust::net::split_url(&url) { Ok(parts) => parts, Err(refusal) => { stash_fault( @@ -699,7 +701,7 @@ fn open_http( // THE SCHEME JUDGEMENT runs UNCONDITIONALLY — a plane-supplied pinned address does NOT let a hop // skip it (FFI-F1). A `http://` downgrade is refused unless the HOST scope admits plaintext. - if let Err(refusal) = crate::net_guard::judge_scheme(&url, https, policy) { + if let Err(refusal) = busbar_kernel_egress::trust::net::judge_scheme(&url, https, policy) { stash_fault( scope, EgressFailClass::Refused, @@ -723,7 +725,9 @@ fn open_http( ip_from_resolved(kind, bytes).map(|ip| std::net::SocketAddr::new(ip, port)) }; if let Some(addr) = pinned { - if let Err(refusal) = crate::net_guard::judge_address(&host_name, addr.ip(), policy) { + if let Err(refusal) = + busbar_kernel_egress::trust::net::judge_address(&host_name, addr.ip(), policy) + { stash_fault( scope, EgressFailClass::Refused, @@ -955,7 +959,7 @@ fn run_http_stream( host_name: &str, port: u16, https: bool, - policy: crate::net_guard::GuardPolicy, + policy: busbar_kernel_egress::trust::net::GuardPolicy, spec: &ReqSpec, identity: (u64, Option), extra_roots: (u64, Vec>), @@ -985,11 +989,11 @@ fn run_http_stream( // address judged, the survivor pinned. let socket_addr = match pinned { Some(addr) => addr, - None => match crate::net_guard::resolve_and_pin( + None => match busbar_kernel_egress::trust::net::resolve_and_pin( host_name, port, https, - &crate::net_guard::SystemResolver, + &crate::egress::trust::InstalledResolver, policy, ) { Ok(pin) => pin.socket_addr(), diff --git a/crates/busbar-kernel/src/plane_host/tests/journal_tests.rs b/crates/busbar-kernel/src/plane_host/tests/journal_tests.rs index 7ec886b8f8..8bf1712ee3 100644 --- a/crates/busbar-kernel/src/plane_host/tests/journal_tests.rs +++ b/crates/busbar-kernel/src/plane_host/tests/journal_tests.rs @@ -258,9 +258,9 @@ extern "C-unwind" fn neutral_reframe( StatusClass::Ok } -/// A generic PERSISTING `Store` double: the shipped `busbar_store_memory` keeps NO plane records -/// (the lossy RAM default), so this delegates the required key/usage/metering surface to a real -/// memory store (so `GovState::new` sees genuine governance behaviour) and PERSISTS the neutral +/// A generic PERSISTING `Store` double: the RAM store keeps NO plane records across a restart (the +/// lossy RAM default), so this delegates the required key/usage/metering surface to the kernel's +/// store double (so `GovState::new` sees genuine governance behaviour) and PERSISTS the neutral /// plane-record verbs generically by `(kind, parent)` — exactly what a durable backend does. struct GenericPlaneStore { inner: crate::governance::MemoryStore, diff --git a/crates/busbar-kernel/src/plane_host/tests/mod_tests.rs b/crates/busbar-kernel/src/plane_host/tests/mod_tests.rs index 7b24be6b9a..1b42d00fa7 100644 --- a/crates/busbar-kernel/src/plane_host/tests/mod_tests.rs +++ b/crates/busbar-kernel/src/plane_host/tests/mod_tests.rs @@ -44,8 +44,10 @@ fn builds_a_full_vtable_with_frozen_preamble() { // linked one does (ARCHITECT SD-3 queue (6)). assert!(vt.entropy_fill.is_some()); assert!(vt.wall_clock.is_some()); - assert!(vt.tap_fault_latch.is_some()); assert!(vt.translate_cap.is_some()); + // RETIRED (row 155): the usage-tap fault is data on the plane's answer, counted by the kernel; + // no host serves the latch slot. + assert!(vt.tap_fault_latch.is_none()); } #[test] diff --git a/crates/busbar-kernel/src/preflight.rs b/crates/busbar-kernel/src/preflight.rs index 7b91a936e1..81932514c2 100644 --- a/crates/busbar-kernel/src/preflight.rs +++ b/crates/busbar-kernel/src/preflight.rs @@ -16,8 +16,8 @@ use crate::diagnostics::{ use crate::{ admin, audit, auth, billing, breaker, catalogue, config, config_validate, core_routes, cost, durable, endpoints, export, failover, governance, handlers, hooks, ingress, ir, json, limits, - net_guard, oauth_as, observability, operation, plane, plugin_routes, profile, proto, proxy, - snapshot, state, store, telemetry, tls, transport, trust, + oauth_as, observability, operation, plane, plugin_routes, profile, proto, proxy, snapshot, + state, store, telemetry, tls, transport, trust, }; /// The FLEET DATA DIR the first-party anti-downgrade floor persists under, or `None` when this @@ -145,12 +145,12 @@ pub type HookAxisBuild = &std::sync::Arc, ) -> Result, String>; -/// A test build has no root: its store fixture and ranking double stand in for the root's entries, the -/// in-crate secret double ([`crate::test_support::secrets`]) as the secret axis, and the test axis -/// for the exports. +/// A test build has no root: its store double ([`crate::test_support::store_double`]) and ranking +/// double stand in for the root's entries, the in-crate secret double +/// ([`crate::test_support::secrets`]) as the secret axis, and the test axis for the exports. #[cfg(any(test, feature = "test-support"))] const STAND_IN: RootInstall = RootInstall { - stores: &[fixture_store::linked::STORE], + stores: &[crate::test_support::store_double::STORE], registry_build: Some(crate::test_support::registry_stand_in), plugins_fetch: Some(crate::test_support::fetch_stand_in), hook_axis: Some(crate::test_support::hook_axis_stand_in), diff --git a/crates/busbar-kernel/src/proto/installed.rs b/crates/busbar-kernel/src/proto/installed.rs index 2b8acaea45..590954f8f1 100644 --- a/crates/busbar-kernel/src/proto/installed.rs +++ b/crates/busbar-kernel/src/proto/installed.rs @@ -250,21 +250,13 @@ static REGISTRY: std::sync::OnceLock = std::sync::OnceLock::new(); /// [`registry`]'s initializer. static INSTALLED: std::sync::OnceLock> = std::sync::OnceLock::new(); -/// Arm the HOST SERVICES a protocol cell reaches through the contract: the usage-tap fault reporter -/// (`handlers::report_usage_tap_decode_failure`) a cell's default usage tap reports through, the -/// usage-tap fault latch (`handlers::usage_tap_decode_fail_should_warn`) a codec whose tap reads a -/// body more than one way counts each way through, the translate-body cap reader +/// Arm the HOST SERVICES a protocol cell reaches through the contract: the translate-body cap reader /// (`proxy::max_translate_body_bytes`, the live operator knob), the host entropy source (the OS /// CSPRNG) and the host wall clock (`store::now`). Called wherever protocols become reachable — /// [`install_protocols`] and the test registration seams — so no cell can be dispatched before they -/// are armed. Idempotent. +/// are armed. Idempotent. A usage-tap fault is no service: the plane returns it on its answer and +/// the kernel counts it (`handlers::usage_tap_fault`). fn arm_host_services() { - busbar_contract::codec::install_usage_tap_fault_reporter( - crate::handlers::report_usage_tap_decode_failure, - ); - busbar_contract::codec::install_usage_tap_fault_latch( - crate::handlers::usage_tap_decode_fail_should_warn, - ); busbar_contract::codec::install_translate_cap_reader(crate::proxy::max_translate_body_bytes); busbar_contract::codec::install_entropy_source(os_entropy); busbar_contract::codec::install_wall_clock(crate::store::now); diff --git a/crates/busbar-kernel/src/proto/tests/installed_tests.rs b/crates/busbar-kernel/src/proto/tests/installed_tests.rs index 9b41d257af..5520d5d257 100644 --- a/crates/busbar-kernel/src/proto/tests/installed_tests.rs +++ b/crates/busbar-kernel/src/proto/tests/installed_tests.rs @@ -44,13 +44,12 @@ fn a_prefix_slice_of_an_interned_name_resolves_its_own_declaration() { ); } -/// THE USAGE-TAP COUNT, HOST STEP (#83a SD-3, architect ruling R-USAGE): the host services the -/// protocol registration arms count every usage-tap fault a codec reports — through the latch (a -/// codec's own reason) or through the decode reporter (a cell's default tap) — as ONE increment of -/// `busbar_billing_tap_decode_fail_total{protocol,reason}`, and warn once per `(protocol, reason)`. -/// The LLM plane's suite pins which reports each tap path makes; this pins what one report counts. +/// THE USAGE-TAP COUNT, KERNEL STEP (#83a SD-3, architect ruling R-USAGE; row 155): every usage-tap +/// fault a plane's answer carries is counted by the kernel as ONE increment of +/// `busbar_billing_tap_decode_fail_total{protocol,reason}`, and warned once per `(protocol, reason)`. +/// The LLM plane's suite pins which fault each tap path returns; this pins what one fault counts. #[test] -fn an_armed_usage_tap_report_counts_one_increment_under_its_reason() { +fn a_usage_tap_fault_counts_one_increment_under_its_reason() { use metrics::{ Counter, CounterFn, Gauge, Histogram, Key, KeyName, Metadata, Recorder, SharedString, Unit, }; @@ -86,17 +85,12 @@ fn an_armed_usage_tap_report_counts_one_increment_under_its_reason() { Histogram::noop() } } - // What registration and `install_protocols` run to arm the host's services. - arm_host_services(); let counts = Counts::default(); let proto = "sd3-armed-tap-protocol"; let (first, second) = metrics::with_local_recorder(&Local(Arc::clone(&counts)), || { - let first = busbar_contract::codec::usage_tap_fault_should_warn(proto, "bad_json"); - let second = busbar_contract::codec::usage_tap_fault_should_warn(proto, "bad_json"); - busbar_contract::codec::report_usage_tap_decode_failure( - proto, - &busbar_contract::codec::CodecError::Malformed("x".into()), - ); + let first = crate::handlers::usage_tap_decode_fail_should_warn(proto, "bad_json"); + let second = crate::handlers::usage_tap_decode_fail_should_warn(proto, "bad_json"); + crate::handlers::usage_tap_fault(proto, "decode"); (first, second) }); assert!( diff --git a/crates/busbar-kernel/src/router.rs b/crates/busbar-kernel/src/router.rs index d8c1e1462e..40bd492d8f 100644 --- a/crates/busbar-kernel/src/router.rs +++ b/crates/busbar-kernel/src/router.rs @@ -10,8 +10,8 @@ use axum::Router; use crate::{ admin, audit, auth, billing, breaker, catalogue, config, config_validate, core_routes, cost, durable, endpoints, export, failover, governance, handlers, hooks, ingress, ir, json, limits, - net_guard, oauth_as, observability, operation, plane, plugin_routes, profile, proto, proxy, - state, store, telemetry, tls, transport, trust, + oauth_as, observability, operation, plane, plugin_routes, profile, proto, proxy, state, store, + telemetry, tls, transport, trust, }; /// Response header name for the W3C Server-Timing field. diff --git a/crates/busbar-kernel/src/store/tests/tests.rs b/crates/busbar-kernel/src/store/tests/tests.rs index f0f91fab77..59b9aa41e9 100644 --- a/crates/busbar-kernel/src/store/tests/tests.rs +++ b/crates/busbar-kernel/src/store/tests/tests.rs @@ -1991,7 +1991,7 @@ fn test_zero_weight_member_is_never_selected() { /// internals; an `Instant`-based in-crate bench measures the EXACT same code with no crate /// surgery — the honest, low-risk way to gate the default path.) #[test] -#[ignore] +#[ignore = "a timing bench: run with --release --ignored --nocapture"] fn bench_select_weighted_in_seam() { let store = Arc::new(HealthState::new(vec![ make_lane_data(0, 100), diff --git a/crates/busbar-kernel/src/test_support/fixtures.rs b/crates/busbar-kernel/src/test_support/fixtures.rs index d447c002b6..649e1dd52b 100644 --- a/crates/busbar-kernel/src/test_support/fixtures.rs +++ b/crates/busbar-kernel/src/test_support/fixtures.rs @@ -15,7 +15,7 @@ use super::*; /// fixture config carries this one. pub fn stand_in_store() -> crate::config::StoreCfg { crate::config::StoreCfg { - module: fixture_store::linked::STORE.0.to_string(), + module: crate::test_support::store_double::STORE.0.to_string(), settings: serde_json::Map::new(), } } diff --git a/crates/busbar-kernel/src/test_support/mod.rs b/crates/busbar-kernel/src/test_support/mod.rs index 57c57ef172..ab67742e0c 100644 --- a/crates/busbar-kernel/src/test_support/mod.rs +++ b/crates/busbar-kernel/src/test_support/mod.rs @@ -2135,6 +2135,10 @@ pub mod hook_double; /// test build. Linked only. pub mod ranking_double; +/// The store double (R-FIX3): the door that stands in for the root's linked store row in a test +/// build, and the `MemoryStore` the kernel's tests build a `GovState` over. Linked only. +pub mod store_double; + /// A [`crate::hooks::HookEnv`] whose registry holds one `kind: hook` row per alias (each stating the /// given manifest `needs`, as a dropped-in plugin's signed manifest does) and whose hook axis is the /// [`hook_double::HookAxisDouble`] answering those aliases, beside the build's linked rows. The diff --git a/crates/busbar-kernel/src/test_support/store_double/mod.rs b/crates/busbar-kernel/src/test_support/store_double/mod.rs new file mode 100644 index 0000000000..59fe83ca15 --- /dev/null +++ b/crates/busbar-kernel/src/test_support/store_double/mod.rs @@ -0,0 +1,926 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (C) 2026 Busbar Inc and contributors + +//! THE STORE DOUBLE (R-FIX3): the in-memory store that stands in for the root's linked store row in +//! a test build, and the universal `RecordStore` the kernel's own tests build a `GovState` over. +//! Linked only. +//! +//! ## Why a double, and why this one (ARCHITECT R-FIX3; THE DESIGN Part 2 #2) +//! +//! The kernel reaches a store only through the store kind's door, and it names no store crate: not +//! in `[dependencies]`, not behind a feature, not under a second name. A test build has no +//! composition root, so the kernel's own tests that boot an app need a linked `kind: store` row +//! whose key is the store a migrated 1.5.x config names ([`MIGRATED_STORE_MODULE`]), and the tests +//! that build a `GovState` need a store that keeps what it is told. A behaviour double the kernel's +//! own tests need lives HERE, as a test-support module of the crate under test, LINKED only — not a +//! workspace crate, not an example plugin, and not the shipped plugin taken under another name. +//! +//! What it copies is the shipped RAM store at the rev the root pins (`848851e63a`): the same record +//! verbs and their preconditions, the same retention sweep, the same v3 slots (dedupe, caps, +//! slices, ledger chains, sessions), the same tail and `max_inflight`. Its Statement name and its +//! canonical name are its OWN ([`NAME`]), never the plugin's: the kernel spells no plugin name, not +//! even in a double (C1). That the copy holds is proven where the real door is linked: the root's +//! `the_kernel_store_double_states_what_the_linked_store_door_states` renders both Statements and +//! drives one verb sequence through both. The plugin's own behaviour is proven in its own repo. +//! +//! EPHEMERAL: every counter, key and credential is lost with the handle. Poison-recovering locks +//! (the governance surface must never panic on a request). + +use crate::config::MIGRATED_STORE_MODULE; + +use busbar_contract::records::{ + AuditRecord, CredentialMeta, CredentialSecret, MeteringDelta, MeteringRow, PlaneDisposition, + PlaneRecord, PlaneRecordRef, PlaneSelector, RecordStore as Store, + RecordStoreError as StoreError, RecordStoreResult as StoreResult, UsageDelta, UsageLedger, + VirtualKey, +}; +// The record half of the store protocol: the three verbs a `PlaneRecord` leg is run over, at the +// contract's own spelling. `StoreError` is imported under a second name because the two protocols +// each carry one and this crate answers both. +use busbar_contract::ids::RecordSchemaId; +use busbar_contract::kinds::{RecordBytes, StoreError as ContractStoreError}; +use std::collections::HashMap; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::RwLock; +use std::time::{SystemTime, UNIX_EPOCH}; + +/// Retention ceiling for `usage`/`metering` rows, keyed by their epoch-second period-start field +/// (`window_start` / `bucket`). Mirrors `busbar::governance`'s own 31-day `max_window` sweep of its +/// in-memory rate-map cells (`crates/busbar-core/src/governance/mod.rs`): this store's ledgers are a +/// durability shadow of that engine state, so retaining them exactly as long as the engine keeps +/// its own cells is the right correspondence, not an arbitrary shorter/longer number. +const MAX_RETENTION_SECS: u64 = 31 * 86_400; + +/// Amortized sweep cadence: one `retain()` pass per this many writes. Mirrors +/// `DEFAULT_RATE_SWEEP_INTERVAL` (`crates/busbar-core/src/config/mod.rs`). +const SWEEP_INTERVAL: u64 = 256; + +/// The `records` map: `(schema, key) -> body`, in KEY ORDER because `record_scan` promises a +/// prefix walk answers in it. +type RecordMap = std::collections::BTreeMap<(String, Vec), RecordBytes>; + +fn now() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) +} + +/// In-memory `Store`: keys by id, row-looked-up credentials by id (`(kind, public_id)` lookup and +/// the per-key listing/cascade are SCANS over that one map, not secondary indexes — the RAM backend +/// holds a fixture-sized table and a second map to keep in step with every sweep, tombstone cascade +/// and rotation is more failure surface than the scan costs), token ledgers keyed by (bucket_id, +/// window_start), metering rows keyed by (key_id, bucket, model, provider, priced_from_ms). +/// A metering row's key: (key_id, bucket, model, provider, priced_from_ms). +type MeteringKey = (String, u64, String, String, u64); + +#[derive(Default)] +pub struct MemoryStore { + keys: RwLock>, + creds: RwLock>, + usage: RwLock>, + metering: RwLock>, + /// The revocation DENYLIST: denied subject ids (1.5.0 signed-token keys). A set (the reason is + /// audit-only and not needed for the enforcement read). + denylist: RwLock>, + /// The SPENT-TOKEN ledger behind `redeem_plane_token`: `(kind, token) -> expires_at`. Presence + /// means "already redeemed", so the test-and-set is an occupied-entry check under one guard. + /// Bounded by dropping lapsed rows on every redemption (a token past its own `expires_at` can + /// never be presented again, so keeping it proves nothing). + plane_tokens: RwLock>, + /// Plane records keyed by `(kind, identity, seq)`. The identity is the record's `parent` for the + /// APPENDED child kinds (a chain is `(parent, seq)`) and its `id` for the upserted ones, which + /// take `seq` 0 — one map serves both because the child kinds never point-read by id. + plane_records: RwLock>, + /// KERNEL-HELD DURABLE RECORDS, under the contract's own three verbs, keyed by + /// `(schema, key)`. + /// + /// A SECOND map beside `plane_records` and deliberately not a re-keying of it. The eight + /// kind-tagged verbs above are the PUBLISHED store protocol the previous release's callers still + /// drive, byte for byte; these three are what `busbar_contract::abi::sdk::store::StoreSlots` declares for a + /// record leg, and they key on an opaque byte string rather than on the `(kind, id, seq)` + /// columns. Folding them onto one map would make every record leg a change to the published + /// path's key shape, which is precisely the thing that has to stay identical. + /// + /// A `BTreeMap` because `record_scan` walks a PREFIX and answers in key order: a hash map would + /// have to sort on every scan, and "the order a scan answers in" is a promise a caller reads a + /// chain by. + /// + /// NEVER AGED OUT. An active work handle is never evicted (`BUSBAR-1.6.0.md` THE DESIGN §1, + /// "Admission bounds live work; nothing evicts it"): a real bound on live work is a refusal at + /// admission, and retention bounds only SETTLED handles. Settledness is a sidecar the store is + /// handed, never a body it decodes — the published path's [`PlaneDisposition`], which + /// `purge_plane_records_before` honours (the conformance suite's + /// `assert_plane_purge_task_keeps_active_rows`). A record-leg row carries no such sidecar: its + /// key is opaque bytes and its value an opaque body, so this backend can never know a row is + /// settled, and a row it cannot prove settled is one it may not drop. Write age is not + /// settledness: an interrupted task waiting on a human is exactly the row nothing rewrites for + /// the longest, and a one-time claim (`records.claim`) dropped by age is a claim that can be + /// made twice. + /// + /// Nor is there a delete verb: `busbar_contract::abi::sdk::store::StoreSlots` declares + /// `record_put`/`record_get`/`record_scan` and nothing that removes a row, so a backend cannot + /// invent one here. The map's bound is the kernel's admission and its own settled-handle sweep, + /// not this backend's clock; and the backend is EPHEMERAL, so a restart empties it. + records: RwLock, + /// The durable admin AUDIT log, by `seq`. EPHEMERAL like every other map here — this backend is + /// RAM — but append-only and fork-detecting within the process's life, which is what the + /// engine's write-through actually asks of a store. Ordered reads come from the `BTreeMap`. + audit: RwLock>, + /// Amortized-sweep write counters for `usage`/`metering`/tombstoned `keys`/revoked `creds` + /// (see `MAX_RETENTION_SECS`). Separate per map since the maps see independent write rates. The + /// `records` map has none: it is never aged out (see its doc). + usage_sweep_ticker: AtomicU64, + metering_sweep_ticker: AtomicU64, + keys_sweep_ticker: AtomicU64, + creds_sweep_ticker: AtomicU64, + /// The store-global monotonic revision counter (see `VirtualKey::revision`). Bumped on every + /// mutation to `keys`/`creds`/the denylist. + revision: AtomicU64, + /// Test-only pinned clock for the retention sweep. `0` (the `Default`) means "use the real wall + /// clock" (`now()`); any non-zero value pins `self.now()` to that epoch-second so a test can make + /// the sweep's retention ceiling EXACTLY match the timestamp the test itself captured. This + /// removes the wall-clock race in the exact-boundary sweep tests, where a one-second tick between + /// the test's `now()` and the sweep's `now()` would otherwise shift the ceiling and evict the + /// "one second inside" row. Prod behavior is untouched: the field is only ever set from tests. + clock: AtomicU64, + /// The store v3 slots' own state: the `op_id` dedupe log, the caps, the drawn totals, the + /// slices, the ledger chains and the session directory ([`v3`]). + v3: v3::State, +} + +impl MemoryStore { + pub fn new() -> Self { + Self::default() + } + + /// Write one of a plane's kernel-held durable records. + /// + /// The verb is `busbar_contract::abi::sdk::store::StoreSlots::record_put`'s, and the three + /// below are its siblings. They are INHERENT rather than a trait implementation for one reason, + /// and it is a rule rather than a preference: the manifest allow-list refuses a store-kind crate + /// that names `busbar-kernel`, so the kernel's own record sink is not a trait this crate may + /// implement; and the contract's `StoreSlots` is the whole store table, of which this + /// backend answers the published half through [`Store`] above. What is here is the record half, + /// at the contract's own spelling, so the adapter that binds a loaded store to the kernel's sink + /// has one shape to forward to rather than two. + /// + /// The value arrives as a [`RecordBytes`], which is where the record ceiling is enforced: a body + /// over it cannot be constructed, so this method cannot be handed one. + /// + /// # Errors + /// + /// Never, for a RAM backend: there is nothing under it to be unavailable. The result is the + /// contract's shape so a durable backend can answer in the same place. + pub fn record_put( + &self, + schema: RecordSchemaId, + key: &[u8], + value: &RecordBytes, + ) -> Result<(), ContractStoreError> { + self.record_put_at(schema.as_str(), key, value); + Ok(()) + } + + /// [`Self::record_put`] under a schema named at run time (the store v3 table's `record_put`). + pub(crate) fn record_put_at(&self, schema: &str, key: &[u8], value: &RecordBytes) { + // No age sweep rides this write: a record-leg row is never evicted by age (see the + // `records` field's doc), because nothing here can tell an active work handle from a + // settled one. + self.records + .write() + .unwrap_or_else(|e| e.into_inner()) + .insert((schema.to_string(), key.to_vec()), value.clone()); + } + + /// Read one of a plane's kernel-held durable records. + /// + /// # Errors + /// + /// Never, for a RAM backend. An absent record is `Ok(None)` and not an error: a plane asking + /// for a row it has not written yet is an ordinary answer, not a fault. + pub fn record_get( + &self, + schema: RecordSchemaId, + key: &[u8], + ) -> Result, ContractStoreError> { + Ok(self.record_get_at(schema.as_str(), key)) + } + + /// [`Self::record_get`] under a schema named at run time. + pub(crate) fn record_get_at(&self, schema: &str, key: &[u8]) -> Option { + self.records + .read() + .unwrap_or_else(|e| e.into_inner()) + .get(&(schema.to_string(), key.to_vec())) + .cloned() + } + + /// Walk a plane's records under a prefix, in key order, at most `limit` of them. + /// + /// `limit` 0 means NOTHING, not everything. A caller that wants the whole prefix names a number; + /// reading zero as unbounded would make a miscomputed bound the one case that returns the entire + /// schema. + /// + /// # Errors + /// + /// Never, for a RAM backend. + pub fn record_scan( + &self, + schema: RecordSchemaId, + prefix: &[u8], + limit: u32, + ) -> Result, RecordBytes)>, ContractStoreError> { + Ok(self.record_scan_at(schema.as_str(), prefix, limit)) + } + + /// [`Self::record_scan`] under a schema named at run time. + pub(crate) fn record_scan_at( + &self, + schema: &str, + prefix: &[u8], + limit: u32, + ) -> Vec<(Vec, RecordBytes)> { + self.records + .read() + .unwrap_or_else(|e| e.into_inner()) + .iter() + .filter(|((s, k), _)| s == schema && k.starts_with(prefix)) + .take(limit as usize) + .map(|((_, k), value)| (k.clone(), value.clone())) + .collect() + } + fn keys(&self) -> std::sync::RwLockWriteGuard<'_, HashMap> { + self.keys.write().unwrap_or_else(|e| e.into_inner()) + } + fn creds(&self) -> std::sync::RwLockWriteGuard<'_, HashMap> { + self.creds.write().unwrap_or_else(|e| e.into_inner()) + } + fn usage(&self) -> std::sync::RwLockWriteGuard<'_, HashMap<(String, u64), UsageLedger>> { + self.usage.write().unwrap_or_else(|e| e.into_inner()) + } + fn metering(&self) -> std::sync::RwLockWriteGuard<'_, HashMap> { + self.metering.write().unwrap_or_else(|e| e.into_inner()) + } + // The SHARED (read) side of the same four locks, for the methods that only ever read. A pure + // read taking the exclusive side serializes every concurrent reader behind it, and the reads + // here are the expensive ones — `list_keys`/`list_credentials`/`list_metering` clone whole + // tables — so a governance `get_key` on the admit path would wait out an admin listing pass. + // Poison-recovering for the same reason the write accessors are: the governance surface must + // never panic on a request. + fn keys_read(&self) -> std::sync::RwLockReadGuard<'_, HashMap> { + self.keys.read().unwrap_or_else(|e| e.into_inner()) + } + fn creds_read(&self) -> std::sync::RwLockReadGuard<'_, HashMap> { + self.creds.read().unwrap_or_else(|e| e.into_inner()) + } + fn usage_read(&self) -> std::sync::RwLockReadGuard<'_, HashMap<(String, u64), UsageLedger>> { + self.usage.read().unwrap_or_else(|e| e.into_inner()) + } + fn metering_read(&self) -> std::sync::RwLockReadGuard<'_, HashMap> { + self.metering.read().unwrap_or_else(|e| e.into_inner()) + } + fn next_revision(&self) -> u64 { + self.revision.fetch_add(1, Ordering::Relaxed) + 1 + } + /// "Now" as the retention sweep sees it: the pinned test clock if set (`clock != 0`), else the + /// real wall clock. See the `clock` field. + fn now(&self) -> u64 { + match self.clock.load(Ordering::Relaxed) { + 0 => now(), + pinned => pinned, + } + } + fn plane_records( + &self, + ) -> std::sync::RwLockWriteGuard<'_, HashMap<(String, String, u64), PlaneRecord>> { + self.plane_records + .write() + .unwrap_or_else(|e| e.into_inner()) + } + fn plane_records_read( + &self, + ) -> std::sync::RwLockReadGuard<'_, HashMap<(String, String, u64), PlaneRecord>> { + self.plane_records.read().unwrap_or_else(|e| e.into_inner()) + } + /// A plane record's identity in the one map: its `parent` when it is an APPENDED child (a chain + /// position is `(parent, seq)`), else its own `id` at `seq` 0. + fn plane_key(record: PlaneRecordRef<'_>) -> (String, String, u64) { + let identity = record.parent.unwrap_or(record.id).to_string(); + (record.kind.to_string(), identity, record.seq) + } + /// The two credential-table preconditions, factored out of `put_credential` so the ATOMIC + /// `put_key_with_credential` can run the identical rules under its own single critical section + /// rather than a second, drifting copy of them. Takes the map (not the guard) so either caller's + /// guard serves. + fn credential_preconditions( + creds: &HashMap, + secret: &CredentialSecret, + ) -> StoreResult<()> { + // Reject an explicit slot pointed at a LIVE credential of the same (key_id, kind) — see the + // trait doc: silently clobbering a working credential mid-overlap-window is almost always an + // operator mistake, not an intended rotation. + let occupied = creds.values().any(|c| { + c.meta.id != secret.meta.id + && c.meta.key_id == secret.meta.key_id + && c.meta.kind == secret.meta.kind + && c.meta.slot == secret.meta.slot + && c.meta.revoked_at.is_none() + }); + if occupied { + return Err(StoreError(format!( + "put_credential: slot {} for key '{}' kind '{}' holds a live credential; revoke it first", + secret.meta.slot, secret.meta.key_id, secret.meta.kind + ))); + } + // UNIQUE(kind, public_id): a public_id must never resolve to two different credentials, + // even across keys (an AccessKeyId is a global lookup handle). + let public_id_taken = creds.values().any(|c| { + c.meta.id != secret.meta.id + && c.meta.kind == secret.meta.kind + && c.meta.public_id == secret.meta.public_id + }); + if public_id_taken { + return Err(StoreError(format!( + "put_credential: public_id '{}' is already in use for kind '{}'", + secret.meta.public_id, secret.meta.kind + ))); + } + Ok(()) + } +} + +impl Store for MemoryStore { + fn put_key(&self, key: &VirtualKey) -> StoreResult<()> { + let mut key = key.clone(); + let mut keys = self.keys(); + // The tombstone precondition, tested and applied under ONE guard acquisition so it is + // atomic — see the trait doc. A live-shaped write over a tombstoned row would resurrect a + // key an operator revoked, and core's caller-side `deleted_at` checks cannot close that: + // they are read-then-write, and a `delete_key` committing in the gap goes straight through. + // A write that CARRIES a tombstone clears nothing and stays allowed. + if key.deleted_at.is_none() { + if let Some(existing) = keys.get(&key.id) { + if existing.deleted_at.is_some() { + return Err(StoreError(format!( + "put_key: '{}' is tombstoned and its id is never reissued; refusing to \ + clear the tombstone", + key.id + ))); + } + } + } + key.revision = self.next_revision(); + keys.insert(key.id.clone(), key); + + // Amortized bounded eviction of stale TOMBSTONES, mirroring `add_usage`/`add_metering` + // above: `keys` tombstones survive `delete_key` forever (by design, for billing/audit + // attribution) but a repeated self-serve issue/refresh loop by one principal is a `put_key` + // hot path, so sweeping it here bounds the map the same way. NEVER prunes a live row (only + // `deleted_at.is_some()` rows are even candidates), and only past the SAME 31-day ceiling + // attribution already stops caring past. + let sweep_needed = self + .keys_sweep_ticker + .fetch_add(1, Ordering::Relaxed) + .wrapping_add(1) + .is_multiple_of(SWEEP_INTERVAL); + if sweep_needed { + let n = self.now(); + keys.retain(|_, k| match k.deleted_at { + None => true, // live rows are never pruned + Some(deleted_at) => deleted_at.saturating_add(MAX_RETENTION_SECS) > n, + }); + } + Ok(()) + } + + fn get_key(&self, id: &str) -> StoreResult> { + Ok(self.keys_read().get(id).cloned()) + } + + fn list_keys(&self) -> StoreResult> { + // Deliberately UNFILTERED — see the trait doc. Tombstones are included so both the admin + // listing caller (which filters live-only itself) and the default `list_keys_since` (which + // needs tombstones visible) are served by this one method. + let mut v: Vec = self.keys_read().values().cloned().collect(); + v.sort_by_key(|k| k.created_at); // mirror SqliteStore's ORDER BY created_at + Ok(v) + } + + fn delete_key(&self, id: &str) -> StoreResult<()> { + // TOMBSTONE (1.5.0 redesign): the key row SURVIVES, so anything that attributes by key id + // (billing/metering rows, audit records) keeps resolving forever, and the id is never + // reissued. Only the CREDENTIALS (live secret material) and the rate/budget `usage` ledger + // are actually removed — `metering` is durable billing evidence and was never cascaded here + // even before this redesign (confirmed: it has its own independent lifecycle from `usage`). + // + // ATOMICITY: hold ALL THREE write guards for the WHOLE cascade rather than taking them + // one-at-a-time, for the same reason as before this redesign — a concurrent write-behind + // `add_usage` must not be able to resurrect a ledger row in the gap. Fixed lock order + // (keys → usage → creds) so this cannot deadlock against any other method. + let mut keys = self.keys(); + let mut usage = self.usage(); + let mut creds = self.creds(); + let Some(key) = keys.get_mut(id) else { + // NOT the idempotent case. "Already tombstoned" (below) means the operator's intent is + // satisfied and the evidence is on disk; "no such id" means nothing was touched, and + // `Ok(())` here tells an operator who typo'd an id that a key was revoked when none was. + return Err(StoreError(format!("delete_key: unknown id '{id}'"))); + }; + if key.deleted_at.is_some() { + return Ok(()); // idempotent: already tombstoned + } + let rev = self.next_revision(); + key.enabled = false; + key.deleted_at = Some(self.now()); + key.revision = rev; + usage.retain(|(k, _), _| k != id); + creds.retain(|_, c| c.meta.key_id != id); + Ok(()) + } + + fn scrub_key(&self, id: &str) -> StoreResult<()> { + let mut keys = self.keys(); + let Some(key) = keys.get_mut(id) else { + return Err(StoreError(format!("scrub_key: unknown key '{id}'"))); + }; + if key.deleted_at.is_none() { + return Err(StoreError(format!( + "scrub_key: '{id}' is not tombstoned — delete it first" + ))); + } + key.name.clear(); + key.labels.clear(); + key.revision = self.next_revision(); + Ok(()) + } + + fn list_keys_since(&self, since: u64) -> StoreResult> { + Ok(self + .keys_read() + .values() + .filter(|k| k.revision > since) + .cloned() + .collect()) + } + + fn get_usage(&self, bucket_id: &str, window_start: u64) -> StoreResult { + Ok(self + .usage_read() + .get(&(bucket_id.to_string(), window_start)) + .cloned() + .unwrap_or_default()) + } + + fn put_usage( + &self, + bucket_id: &str, + window_start: u64, + ledger: &UsageLedger, + ) -> StoreResult<()> { + // Write-behind ABSOLUTE set (memory is authoritative in the engine; this is durability only). + self.usage() + .insert((bucket_id.to_string(), window_start), ledger.clone()); + Ok(()) + } + + fn add_usage(&self, bucket_id: &str, window_start: u64, delta: &UsageDelta) -> StoreResult<()> { + // ADDITIVE accumulate under the write lock (atomic within this process), floored at 0. + let mut usage = self.usage(); + let u = usage + .entry((bucket_id.to_string(), window_start)) + .or_default(); + u.apply_delta(delta); + + // Amortized bounded eviction of stale windows, on the write-behind hot path + // (`flush_budgets` calls `add_usage` on every tick). `put_usage` (the absolute-set path) is + // deliberately NOT swept: `add_usage` is the common/hot path so sweeping it alone is + // sufficient to bound growth, and skipping `put_usage` keeps this change minimal. + let sweep_needed = self + .usage_sweep_ticker + .fetch_add(1, Ordering::Relaxed) + .wrapping_add(1) + .is_multiple_of(SWEEP_INTERVAL); + if sweep_needed { + let n = self.now(); + usage + .retain(|(_, window_start), _| window_start.saturating_add(MAX_RETENTION_SECS) > n); + } + Ok(()) + } + + fn add_metering(&self, d: &MeteringDelta) -> StoreResult<()> { + let mut m = self.metering(); + let e = m + // `priced_from_ms` is part of the key, not just the row: it is the `effective_from` + // of the rate-card entry in force when these counts were accrued, so a card edit inside + // a UTC day opens a SECOND row for that day and each half keeps the card it was earned + // under (ruling #79). Folding it into one row would leave a sum earned under two + // cards with only one card to be read against. + .entry(( + d.key_id.clone(), + d.bucket, + d.model.clone(), + d.provider.clone(), + d.priced_from_ms, + )) + .or_insert_with(|| MeteringRow { + key_id: d.key_id.clone(), + model: d.model.clone(), + provider: d.provider.clone(), + tokens_input: 0, + tokens_output: 0, + tokens_cache_read: 0, + tokens_cache_write: 0, + requests: 0, + billable_requests: 0, + key_group_at_use: d.key_group_at_use.clone(), + pricing_version: d.pricing_version.clone(), + priced_from_ms: d.priced_from_ms, + usage_units: std::collections::BTreeMap::new(), + }); + e.tokens_input = e.tokens_input.saturating_add(d.tokens_input); + e.tokens_output = e.tokens_output.saturating_add(d.tokens_output); + e.tokens_cache_read = e.tokens_cache_read.saturating_add(d.tokens_cache_read); + e.tokens_cache_write = e.tokens_cache_write.saturating_add(d.tokens_cache_write); + e.requests = e.requests.saturating_add(d.requests); + e.billable_requests = e.billable_requests.saturating_add(d.billable_requests); + // Every ledgered class the token columns do not hold, additive like them. + for (class, n) in &d.usage_units { + let cur = e.usage_units.entry(class.clone()).or_insert(0); + *cur = cur.saturating_add(*n); + } + + // Amortized bounded eviction of stale buckets, mirroring `add_usage` above. + let sweep_needed = self + .metering_sweep_ticker + .fetch_add(1, Ordering::Relaxed) + .wrapping_add(1) + .is_multiple_of(SWEEP_INTERVAL); + if sweep_needed { + let n = self.now(); + m.retain(|(_, bucket, _, _, _), _| bucket.saturating_add(MAX_RETENTION_SECS) > n); + } + Ok(()) + } + + fn list_metering(&self, bucket: u64) -> StoreResult> { + Ok(self + .metering_read() + .iter() + .filter(|((_, b, _, _, _), _)| *b == bucket) + .map(|(_, row)| row.clone()) + .collect()) + } + + fn put_credential(&self, secret: &CredentialSecret) -> StoreResult<()> { + // The owning key is read under the SAME critical section as the credential write, in + // `delete_key`'s fixed lock order (keys → creds) so the two can never deadlock against each + // other. Checking the key first and writing after would let a `delete_key` commit in the + // gap: its cascade removes the credentials that exist AT THAT MOMENT, so material written + // just behind it survives the tombstone and keeps resolving — the same read-then-write hole + // `put_key`'s own tombstone precondition closes, one door over. + let keys = self.keys_read(); + let mut creds = self.creds(); + let Some(owner) = keys.get(&secret.meta.key_id) else { + return Err(StoreError(format!( + "put_credential: key '{}' does not exist; a credential must hang off a real key", + secret.meta.key_id + ))); + }; + if owner.deleted_at.is_some() { + return Err(StoreError(format!( + "put_credential: key '{}' is tombstoned; its credentials were revoked with it and \ + are never reissued", + secret.meta.key_id + ))); + } + Self::credential_preconditions(&creds, secret)?; + let mut secret = secret.clone(); + secret.meta.revision = self.next_revision(); + creds.insert(secret.meta.id.clone(), secret); + + // Amortized bounded eviction of stale REVOKED credentials, mirroring `put_key`'s tombstone + // sweep above: `creds` had NO retention sweep at all before this (the only prior shrink path + // was `delete_key`'s cascade, which never fires for a credential rotated on a LIVE key), so a + // long-lived key's occupied-slot -> revoke -> re-put rotation cycle grew this map without + // bound. A row is a candidate ONLY once `revoked_at` is set — a LIVE (unrevoked) credential is + // NEVER pruned regardless of age, exactly like a live `VirtualKey` is never a `put_key` sweep + // candidate — so this can never evict a credential a live key is still presenting. Age is + // measured from `revoked_at` (when the credential stopped being usable), not `created_at`, so + // a credential that lived (unrevoked) for years isn't punished the instant it's rotated out — + // only ages PAST the 31-day ceiling once it's actually dead. + let sweep_needed = self + .creds_sweep_ticker + .fetch_add(1, Ordering::Relaxed) + .wrapping_add(1) + .is_multiple_of(SWEEP_INTERVAL); + if sweep_needed { + let n = self.now(); + creds.retain(|_, c| match c.meta.revoked_at { + None => true, // live credentials are never pruned + Some(revoked_at) => revoked_at.saturating_add(MAX_RETENTION_SECS) > n, + }); + } + Ok(()) + } + + fn put_key_with_credential( + &self, + key: &VirtualKey, + secret: &CredentialSecret, + ) -> StoreResult<()> { + // The trait calls this mint ATOMIC, and the DEFAULT it inherits is `put_key` followed by + // `put_credential` — two independent critical sections. When the credential leg fails (a + // reused `public_id` is the ordinary way it does), the key leg has already committed, so the + // caller is told the mint failed while a bearer key with no credential is left live in the + // table: a row nobody will ever clean up, and a `put_key` the operator never asked for. Both + // rows go in under ONE acquisition of the same two guards `delete_key` takes, in the same + // fixed order, with every precondition tested before anything is written. + let mut keys = self.keys(); + let mut creds = self.creds(); + if key.deleted_at.is_none() { + if let Some(existing) = keys.get(&key.id) { + if existing.deleted_at.is_some() { + return Err(StoreError(format!( + "put_key_with_credential: '{}' is tombstoned and its id is never reissued", + key.id + ))); + } + } + } + // The credential must name the key being minted alongside it — a mint that quietly hung its + // secret material off some OTHER key is not the operation the caller asked for, and the + // tombstone/existence check `put_credential` makes cannot apply to a key that does not exist + // until this call commits. + if secret.meta.key_id != key.id { + return Err(StoreError(format!( + "put_key_with_credential: the credential names key '{}', not the key '{}' being \ + minted with it", + secret.meta.key_id, key.id + ))); + } + Self::credential_preconditions(&creds, secret)?; + + let mut key = key.clone(); + key.revision = self.next_revision(); + keys.insert(key.id.clone(), key); + let mut secret = secret.clone(); + secret.meta.revision = self.next_revision(); + creds.insert(secret.meta.id.clone(), secret); + Ok(()) + } + + fn list_credentials(&self, key_id: &str) -> StoreResult> { + Ok(self + .creds_read() + .values() + .filter(|c| c.meta.key_id == key_id) + .map(|c| c.meta.clone()) + .collect()) + } + + fn lookup_credential_secret( + &self, + kind: &str, + public_id: &str, + ) -> StoreResult> { + Ok(self + .creds_read() + .values() + .find(|c| c.meta.kind == kind && c.meta.public_id == public_id) + .cloned()) + } + + fn revoke_credential(&self, id: &str, reason: &str) -> StoreResult<()> { + let mut creds = self.creds(); + let Some(c) = creds.get_mut(id) else { + return Err(StoreError(format!("revoke_credential: unknown id '{id}'"))); + }; + if c.meta.revoked_at.is_none() { + // `self.now()` (pinned-clock-aware), not the bare free function — the `creds` sweep + // added for the retention fix ages rows off `revoked_at` against `self.now()`, so + // stamping it from any other clock would desync the sweep's boundary from what a test + // (or, in prod, a paused/adjusted clock) actually pinned. Matches `delete_key`'s + // `deleted_at = Some(self.now())` for the same reason. + c.meta.revoked_at = Some(self.now()); + c.meta.revoke_reason = Some(reason.to_string()); + c.meta.revision = self.next_revision(); + } // idempotent: already revoked + Ok(()) + } + + fn list_credentials_since(&self, since: u64) -> StoreResult> { + Ok(self + .creds_read() + .values() + .filter(|c| c.meta.revision > since) + .cloned() + .collect()) + } + + fn add_denylist(&self, sub: &str, _reason: &str) -> StoreResult<()> { + self.denylist + .write() + .unwrap_or_else(|e| e.into_inner()) + .insert(sub.to_string()); + Ok(()) + } + + fn list_denylist(&self) -> StoreResult> { + Ok(self + .denylist + .read() + .unwrap_or_else(|e| e.into_inner()) + .iter() + .cloned() + .collect()) + } + + fn append_audit(&self, entry: &AuditRecord) -> StoreResult<()> { + // Append-only, never rewriting: a second record on an occupied `seq` is EITHER the + // write-through retrying (byte-identical → Ok, the common case) or a forked/tampered chain + // (different → error). Collapsing those two is the one thing an audit store must not do. + let mut audit = self.audit.write().unwrap_or_else(|e| e.into_inner()); + match audit.get(&entry.seq) { + Some(stored) if stored == entry => Ok(()), + Some(_) => Err(StoreError(format!( + "append_audit: seq {} already holds a DIFFERENT record — the audit chain has forked", + entry.seq + ))), + None => { + audit.insert(entry.seq, entry.clone()); + Ok(()) + } + } + } + + fn list_audit(&self) -> StoreResult> { + Ok(self + .audit + .read() + .unwrap_or_else(|e| e.into_inner()) + .values() + .cloned() + .collect()) + } + + fn upsert_plane_record(&self, record: PlaneRecordRef<'_>) -> StoreResult<()> { + self.plane_records() + .insert(Self::plane_key(record), record.to_record()); + Ok(()) + } + + fn get_plane_record(&self, kind: &str, id: &str) -> StoreResult>> { + Ok(self + .plane_records_read() + .get(&(kind.to_string(), id.to_string(), 0)) + .map(|r| r.body.clone())) + } + + fn append_plane_record(&self, record: PlaneRecordRef<'_>) -> StoreResult<()> { + // Keyed by `(parent, seq)`. APPEND-ONLY, never a blind overwrite — mirrors `append_audit`'s + // own fork detection above so the two append-only paths can never disagree about what a + // fork is: a second record at an already-occupied position is EITHER the write-through + // retrying after a timeout (byte-identical → Ok, the common case) or a SECOND WRITER forking + // the chain (different → refused, never silently applied). A blind upsert here would let two + // busbar processes pointed at one durable store silently overwrite each other's + // `task_event`/`call`/`audit` rows — exactly the defect `append_audit`'s own check exists to + // catch, restored on this newer seam. + let mut records = self.plane_records(); + let key = Self::plane_key(record); + match records.get(&key) { + Some(existing) if existing.view() == record => Ok(()), + Some(_) => Err(StoreError(format!( + "append_plane_record: kind '{}' parent '{}' seq {} already holds a DIFFERENT \ + record — the chain has forked", + record.kind, + record.parent.unwrap_or(record.id), + record.seq + ))), + None => { + records.insert(key, record.to_record()); + Ok(()) + } + } + } + + fn list_plane_records( + &self, + kind: &str, + selector: &PlaneSelector<'_>, + ) -> StoreResult>> { + let records = self.plane_records_read(); + let mut rows: Vec<(u64, Vec)> = records + .iter() + .filter(|((k, _, _), r)| { + k == kind + && match selector { + PlaneSelector::All => true, + PlaneSelector::Parent(p) => r.parent.as_deref() == Some(&**p), + } + }) + .map(|(_, r)| (r.seq, r.body.clone())) + .collect(); + // Oldest-first by `seq` — the order the engine's chain verifier reads a parent's events in. + rows.sort_by_key(|(seq, _)| *seq); + Ok(rows.into_iter().map(|(_, body)| body).collect()) + } + + fn list_plane_record_parents(&self, kind: &str) -> StoreResult> { + let records = self.plane_records_read(); + let mut parents: Vec = records + .iter() + .filter(|((k, _, _), _)| k == kind) + .filter_map(|(_, r)| r.parent.clone()) + .collect(); + parents.sort(); + parents.dedup(); + Ok(parents) + } + + fn purge_plane_records_before(&self, kind: &str, before: u64) -> StoreResult { + // WHICH rows go is the kind's contract: `task` drops only TERMINAL rows (an interrupted task + // waiting on a human is exactly the row that sits still longest, and dropping it loses the + // work), every other kind drops any row older than `before`. + let mut records = self.plane_records(); + let before_len = records.len(); + records.retain(|(k, _, _), r| { + if k != kind || r.ts >= before { + return true; + } + k == "task" && r.disposition != PlaneDisposition::Terminal + }); + Ok((before_len - records.len()) as u64) + } + + fn delete_plane_record(&self, kind: &str, id: &str) -> StoreResult<()> { + // Absent is a no-op, per the trait. Every `seq` under the identity goes, so deleting a + // parent's record cannot leave part of a chain behind. + self.plane_records() + .retain(|(k, i, _), _| !(k == kind && i == id)); + Ok(()) + } + + fn redeem_plane_token( + &self, + kind: &str, + token: &str, + expires_at: u64, + now: u64, + ) -> StoreResult { + // TEST-AND-SET under ONE guard. The trait's default is `Ok(true)` — "this store keeps no + // ledger" — which on the DEFAULT backend makes every single-use approval token replayable: + // a confirm-once tool re-executes for anyone who replays the nonce, and the store reports + // each replay as the first redemption. Governance's out-of-the-box posture cannot be that. + let mut spent = self.plane_tokens.write().unwrap_or_else(|e| e.into_inner()); + // Lapsed rows go in the same call: a token past its own expiry can never be presented + // again, so retaining it only grows the map. + spent.retain(|_, exp| *exp > now); + let first = spent + .insert((kind.to_string(), token.to_string()), expires_at) + .is_none(); + Ok(first) + } + + fn plane_token_live( + &self, + kind: &str, + token: &str, + expires_at: u64, + now: u64, + ) -> StoreResult { + // MULTI-use and SPENDS NOTHING — the opposite of `redeem_plane_token`'s single-use + // test-and-set. This is a plain READ of the `(kind, token)` upsert record (an upsert kind + // lives at `seq` 0, exactly where `get_plane_record` point-reads it), live only while that + // record is present, its disposition is still `Active` (non-terminal), and `now` has not + // reached `expires_at`. Nothing is inserted, removed or mutated, so asking twice answers the + // same twice — the whole point of the verb. + // + // The trait default is `Ok(false)` precisely so a backend that keeps no records refuses these + // callbacks fail-closed. This backend DOES keep them, in the same `plane_records` map the + // upsert leg wrote to, so it answers from there — and it stays fail-closed for the same three + // reasons: an unknown `(kind, token)`, a terminal disposition, and a lapsed deadline each + // yield `Ok(false)`. + Ok(self + .plane_records_read() + .get(&(kind.to_string(), token.to_string(), 0)) + .is_some_and(|r| r.disposition == PlaneDisposition::Active && now < expires_at)) + } +} + +/// The double's own Statement name and canonical name: not the plugin's (C1). +pub const NAME: &str = "store-double"; + +/// THE STAND-IN LINKED ENTRY: `(key, ephemeral, door, canonical)`, the shape of the root's linked +/// store rows ([`crate::preflight::LinkedStore`]). Its key is the store a migrated 1.5.x config names +/// ([`MIGRATED_STORE_MODULE`]), so a fixture config's `store:` block resolves to it; it claims no +/// default (Q-STORE = (B)). +pub const STORE: crate::preflight::LinkedStore = (MIGRATED_STORE_MODULE, true, door, NAME); + +/// THE STORE DOOR (store v3, `busbar_contract::abi::store`): every slot of the store v3 table over +/// [`MemoryStore`], through the contract's store SDK (`abi::sdk::store`) — what [`STORE`] registers, +/// and what boot opens through the stand-in store axis. +pub use v3::door; + +mod v3; diff --git a/crates/busbar-kernel/src/test_support/store_double/v3.rs b/crates/busbar-kernel/src/test_support/store_double/v3.rs new file mode 100644 index 0000000000..c3b42e8e36 --- /dev/null +++ b/crates/busbar-kernel/src/test_support/store_double/v3.rs @@ -0,0 +1,933 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (C) 2026 Busbar Inc and contributors + +//! The store v3 slots this backend answers beyond the 1.5.5 op set ([`StoreSlots`]): the +//! `op_id`-carrying writes, the ledger ops, the money slots and `window_caps`. +//! +//! DEDUPE (`abi::store` S1-S4): every `op_id` this store APPLIED is remembered, with the op's value +//! fields and its answer, for [`OP_ID_RETENTION_SECS`]. A replay with the same value fields applies +//! nothing and answers the original; different value fields are a conflict. The dedupe log, the +//! caps, the drawn totals and the slices live under ONE lock, and an op's dedupe check, its effect +//! and its record happen under it, so two racing calls with one `op_id` apply once. This store is +//! EPHEMERAL (its Statement tail says so): the log is lost on restart together with everything it +//! guards, so a replay after a restart re-applies onto an empty store, which is the state the +//! restart left. +//! +//! EPOCH AND SLICE LIFE (`abi::store::SLICE_TTL_MS`, the store kind's spec): this is a NODE-LOCAL +//! store, and its rule holds for a SINGLE node only. It holds one constant epoch, never answers a +//! stale one (the `epoch` a caller states is accepted as given) and grants slices that never expire +//! (`valid_until_ms = u64::MAX`): no other node can draw against its windows. A store a fleet shares +//! persists and fences the epoch and bounds every slice. A release, here as everywhere, returns at +//! most what the slice has left, and a slice with nothing left returns `0`. +//! +//! GRANT SIZE: a cell grants its whole `amount` or the reserve fails; the per-dimension test +//! is 1.5.5's, cited on `abi::store::ReserveIn`. + +use std::collections::{HashMap, VecDeque}; +use std::sync::{Mutex, MutexGuard}; + +use busbar_contract::abi::sdk::conn::Host; +use busbar_contract::abi::sdk::store::{ + Cap, CapsRefused, Cell, CellKey, Dimension, Grant, Op, OpRefused, OpResult, ReserveRefused, + Scanned, Step, StoreSlots, Tail, +}; +use busbar_contract::abi::store::{OpId, OP_ID_RETENTION_SECS}; +use busbar_contract::kinds::{Head, RecordBytes}; +use busbar_contract::records::{ + AuditRecord, CredentialMeta, CredentialSecret, MeteringDelta, MeteringRow, PlaneRecordRef, + PlaneSelector, RecordStore, RecordStoreResult, UsageDelta, UsageLedger, VirtualKey, +}; + +use super::{MemoryStore, NAME}; + +busbar_contract::store_door!(MemoryStore, NAME, env!("CARGO_PKG_VERSION"), 1024); + +/// A slot key owned by the store: `(bucket, pool, dimension, class_key, window_start)`. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +struct Slot { + bucket: String, + pool: Option, + dimension: u32, + class_key: String, + window_start: u64, +} + +impl Slot { + fn of(k: &CellKey<'_>) -> Self { + let (dimension, class_key) = match k.dimension { + Dimension::NanoUnits => (0, ""), + Dimension::Requests => (1, ""), + Dimension::Concurrency => (2, ""), + Dimension::Class(c) => (3, c), + }; + Self { + bucket: k.bucket.to_string(), + pool: k.pool.map(str::to_string), + dimension, + class_key: class_key.to_string(), + window_start: k.window_start, + } + } +} + +/// What an applied op answered, replayed verbatim. +#[derive(Debug, Clone)] +enum Answer { + Done, + Head(Head), + Grants(Vec), + Released(Vec), +} + +/// One remembered op: its value fields and its answer. +struct Recorded { + body: String, + answer: Answer, +} + +/// A drawn slice: the slot it drew from and what it still holds. +struct Drawn { + slot: Slot, + left: u64, +} + +#[derive(Default)] +struct Inner { + ops: HashMap, + /// `(recorded_at, op_id)`, oldest first: the retention sweep's order. + aged: VecDeque<(u64, OpId)>, + /// `slot -> (cap, config_gen)`. + caps: HashMap, + /// `slot -> drawn and not released`. + used: HashMap, + slices: HashMap, + /// The slices whose unreturned unspent reached `0`, with when: a subsequent release of one + /// returns `0`. Kept as long as an `op_id` is (`OP_ID_RETENTION_SECS`), then forgotten. + closed: HashMap, + next_slice: u64, + /// `stream -> its records`; the head's `seq` is the count. + journals: HashMap>, + /// `session -> (node, principal)`. + sessions: HashMap, +} + +/// The v3 state [`MemoryStore`] carries. +#[derive(Default)] +pub(crate) struct State { + inner: Mutex, +} + +/// Whether an op is new, a replay, or a conflict. +enum Seen { + New, + Replay(Answer), + Conflict, +} + +impl Inner { + fn seen(&self, op: OpId, body: &str) -> Seen { + match self.ops.get(&op) { + None => Seen::New, + Some(r) if r.body == body => Seen::Replay(r.answer.clone()), + Some(_) => Seen::Conflict, + } + } + + /// Remember an APPLIED op, and forget every op past its retention. + fn record(&mut self, now: u64, op: OpId, body: String, answer: Answer) { + while let Some(&(at, old)) = self.aged.front() { + if at.saturating_add(OP_ID_RETENTION_SECS) > now { + break; + } + self.aged.pop_front(); + self.ops.remove(&old); + } + self.ops.insert(op, Recorded { body, answer }); + self.aged.push_back((now, op)); + } +} + +impl State { + fn lock(&self) -> MutexGuard<'_, Inner> { + self.inner.lock().unwrap_or_else(|p| p.into_inner()) + } +} + +/// The 1.5.5 admission test for one cell (`abi::store::ReserveIn`, GRANT SIZE): whether drawing +/// `amount` onto `used` under `cap` is refused. `used + amount` is checked: an overflow refuses. +fn exhausted(dimension: u32, used: u64, amount: u64, cap: u64) -> bool { + let Some(after) = used.checked_add(amount) else { + return true; + }; + match dimension { + // DIM_CLASS: `tokens >= cap` — the draw that crosses the cap is granted whole. + 3 => used >= cap, + // DIM_NANO_UNITS: `derived >= cap || derived + fee > cap`. + 0 => used >= cap || after > cap, + // DIM_REQUESTS / DIM_CONCURRENCY: `used + amount > cap`. + _ => after > cap, + } +} + +fn failed(e: busbar_contract::records::RecordStoreError) -> OpRefused { + OpRefused::Failed(e.0) +} + +impl MemoryStore { + /// Run one `op_id`-carrying write under the v3 lock: a replay answers the original, a + /// conflict applies nothing, and a new op runs `apply` and is remembered only if it applied. + fn deduped( + &self, + op: OpId, + body: String, + apply: impl FnOnce(&Self, &mut Inner) -> OpResult, + ) -> OpResult { + let mut inner = self.v3.lock(); + match inner.seen(op, &body) { + Seen::Replay(a) => Ok(a), + Seen::Conflict => Err(OpRefused::Conflict), + Seen::New => { + let answer = apply(self, &mut inner)?; + inner.record(self.now(), op, body, answer.clone()); + Ok(answer) + } + } + } + + /// Every audit record in `entries` would append (none forks a stored `seq`, nor another in the + /// batch), checked before any is applied. + fn audit_fits(&self, entries: &[AuditRecord]) -> OpResult<()> { + let audit = self.audit.read().unwrap_or_else(|e| e.into_inner()); + let mut batch: HashMap = HashMap::new(); + for e in entries { + let prior = audit.get(&e.seq).or_else(|| batch.get(&e.seq).copied()); + if prior.is_some_and(|p| p != e) { + return Err(OpRefused::Failed(format!( + "append_audit: seq {} already holds a DIFFERENT record — the audit chain has forked", + e.seq + ))); + } + batch.insert(e.seq, e); + } + Ok(()) + } +} + +/// The store v3 slots' own bodies: each op's ONE body, which the table's slot answers inline (the +/// memory store never pends). +impl MemoryStore { + pub(crate) fn v3_add_usage_op( + &self, + op: OpId, + bucket: &str, + window_start: u64, + delta: &UsageDelta, + ) -> OpResult<()> { + let body = format!("add_usage:{bucket:?}:{window_start}:{delta:?}"); + self.deduped(op, body, |s, _| { + s.add_usage(bucket, window_start, delta).map_err(failed)?; + Ok(Answer::Done) + }) + .map(drop) + } + + pub(crate) fn v3_add_metering_op(&self, op: OpId, delta: &MeteringDelta) -> OpResult<()> { + let body = format!("add_metering:{delta:?}"); + self.deduped(op, body, |s, _| { + s.add_metering(delta).map_err(failed)?; + Ok(Answer::Done) + }) + .map(drop) + } + + pub(crate) fn v3_append_audit_op(&self, op: OpId, entry: &AuditRecord) -> OpResult<()> { + let body = format!("append_audit:{entry:?}"); + self.deduped(op, body, |s, _| { + s.append_audit(entry).map_err(failed)?; + Ok(Answer::Done) + }) + .map(drop) + } + + pub(crate) fn v3_append_plane_record_op( + &self, + op: OpId, + record: PlaneRecordRef<'_>, + ) -> OpResult<()> { + let body = format!("append_plane_record:{:?}", record.to_record()); + self.deduped(op, body, |s, _| { + s.append_plane_record(record).map_err(failed)?; + Ok(Answer::Done) + }) + .map(drop) + } + + pub(crate) fn v3_append_batch( + &self, + op: OpId, + stream: &str, + records: &[RecordBytes], + ) -> OpResult { + let body = format!("append_batch:{stream:?}:{records:?}"); + let answer = self.deduped(op, body, |_, inner| { + let rows = inner.journals.entry(stream.to_string()).or_default(); + rows.extend(records.iter().cloned()); + Ok(Answer::Head(Head { + seq: rows.len() as u64, + epoch: 0, + })) + })?; + match answer { + Answer::Head(h) => Ok(h), + _ => Err(OpRefused::Conflict), + } + } + + pub(crate) fn v3_heads(&self) -> Result, String> { + let inner = self.v3.lock(); + let mut heads: Vec<(String, Head)> = inner + .journals + .iter() + .map(|(s, rows)| { + ( + s.clone(), + Head { + seq: rows.len() as u64, + epoch: 0, + }, + ) + }) + .collect(); + heads.sort_by(|a, b| a.0.cmp(&b.0)); + Ok(heads) + } + + pub(crate) fn v3_session_put( + &self, + session: u64, + node: &str, + principal: &str, + ) -> Result<(), String> { + self.v3 + .lock() + .sessions + .insert(session, (node.to_string(), principal.to_string())); + Ok(()) + } + + pub(crate) fn v3_session_remove(&self, session: u64) -> Result<(), String> { + self.v3.lock().sessions.remove(&session); + Ok(()) + } + + pub(crate) fn v3_sessions_for(&self, principal: &str) -> Result, String> { + let inner = self.v3.lock(); + let mut rows: Vec<(u64, String)> = inner + .sessions + .iter() + .filter(|(_, (_, p))| p == principal) + .map(|(s, (n, _))| (*s, n.clone())) + .collect(); + rows.sort_unstable(); + Ok(rows) + } + + pub(crate) fn v3_record_put( + &self, + schema: &str, + key: &[u8], + value: &[u8], + ) -> Result<(), String> { + // The store keeps the record, so it copies it here (the door lends the host's bytes). + let value = RecordBytes::new(value.to_vec()) + .map_err(|n| format!("a record of {n} bytes is over the ceiling"))?; + self.record_put_at(schema, key, &value); + Ok(()) + } + + pub(crate) fn v3_record_get( + &self, + schema: &str, + key: &[u8], + ) -> Result, String> { + Ok(self.record_get_at(schema, key)) + } + + pub(crate) fn v3_record_scan( + &self, + schema: &str, + prefix: &[u8], + limit: u32, + ) -> Result, RecordBytes)>, String> { + Ok(self.record_scan_at(schema, prefix, limit)) + } + + pub(crate) fn v3_reserve<'c>( + &self, + op: OpId, + epoch: u64, + cells: impl Iterator> + Clone, + grants: &mut impl Extend, + ) -> Result<(), ReserveRefused> { + let cells: Vec> = cells.collect(); + let body = format!("reserve:{epoch}:{cells:?}"); + let mut inner = self.v3.lock(); + match inner.seen(op, &body) { + Seen::Replay(Answer::Grants(g)) => { + grants.extend(g); + return Ok(()); + } + Seen::Replay(_) | Seen::Conflict => return Err(ReserveRefused::Conflict), + Seen::New => {} + } + // The chain draw is all or nothing: test every cell against what the cells before it in + // THIS draw add, and apply only when every cell passes. + let mut drawn: HashMap = HashMap::new(); + let mut slots = Vec::with_capacity(cells.len()); + for (i, c) in cells.iter().enumerate() { + let slot = Slot::of(&c.key); + let Some(&(cap, _)) = inner.caps.get(&slot) else { + return Err(ReserveRefused::NoCap { cell: i as u32 }); + }; + let used = inner.used.get(&slot).copied().unwrap_or(0); + let used = used.saturating_add(drawn.get(&slot).copied().unwrap_or(0)); + if exhausted(slot.dimension, used, c.amount, cap) { + return Err(ReserveRefused::Exhausted { cell: i as u32 }); + } + *drawn.entry(slot.clone()).or_default() += c.amount; + slots.push(slot); + } + let mut granted = Vec::with_capacity(cells.len()); + for (c, slot) in cells.iter().zip(slots) { + inner.next_slice += 1; + let slice_id = inner.next_slice; + let used = inner.used.entry(slot.clone()).or_default(); + *used = used.saturating_add(c.amount); + inner.slices.insert( + slice_id, + Drawn { + slot, + left: c.amount, + }, + ); + granted.push(Grant { + slice_id, + granted: c.amount, + // Single node: nothing expires a slice no other node can draw against. + valid_until_ms: u64::MAX, + }); + } + grants.extend(granted.iter().copied()); + inner.record(self.now(), op, body, Answer::Grants(granted)); + Ok(()) + } + + pub(crate) fn v3_slice_release( + &self, + op: OpId, + epoch: u64, + items: impl Iterator + Clone, + released: &mut impl Extend, + ) -> OpResult<()> { + let items: Vec<(u64, u64)> = items.collect(); + let body = format!("slice_release:{epoch}:{items:?}"); + let now = self.now(); + let answer = self.deduped(op, body, |_, inner| { + if inner.closed.len() > 1024 { + inner + .closed + .retain(|_, at| at.saturating_add(OP_ID_RETENTION_SECS) > now); + } + let known = |id: &u64| inner.slices.contains_key(id) || inner.closed.contains_key(id); + if let Some((id, _)) = items.iter().find(|(id, _)| !known(id)) { + return Err(OpRefused::Failed(format!( + "slice_release: slice {id} was never granted" + ))); + } + let mut back_all = Vec::with_capacity(items.len()); + for &(id, unspent) in &items { + // A slice closed once its unreturned unspent reached 0 (by an earlier release, or an + // earlier item of this call) returns nothing more. + let Some(d) = inner.slices.get_mut(&id) else { + back_all.push(0); + continue; + }; + let back = unspent.min(d.left); + d.left -= back; + let slot = d.slot.clone(); + if d.left == 0 { + inner.slices.remove(&id); + inner.closed.insert(id, now); + } + if let Some(u) = inner.used.get_mut(&slot) { + *u = u.saturating_sub(back); + } + back_all.push(back); + } + Ok(Answer::Released(back_all)) + })?; + match answer { + Answer::Released(r) => { + released.extend(r); + Ok(()) + } + _ => Err(OpRefused::Conflict), + } + } + + pub(crate) fn v3_add_usage_batch( + &self, + op: OpId, + cells: &[(&str, u64, UsageDelta)], + ) -> OpResult<()> { + let body = format!("add_usage_batch:{cells:?}"); + self.deduped(op, body, |s, _| { + // A RAM ledger cannot fail an add, so applying in order is atomic. + for (bucket, window, delta) in cells { + s.add_usage(bucket, *window, delta).map_err(failed)?; + } + Ok(Answer::Done) + }) + .map(drop) + } + + pub(crate) fn v3_add_metering_batch(&self, op: OpId, deltas: &[MeteringDelta]) -> OpResult<()> { + let body = format!("add_metering_batch:{deltas:?}"); + self.deduped(op, body, |s, _| { + for d in deltas { + s.add_metering(d).map_err(failed)?; + } + Ok(Answer::Done) + }) + .map(drop) + } + + pub(crate) fn v3_append_audit_batch(&self, op: OpId, entries: &[AuditRecord]) -> OpResult<()> { + let body = format!("append_audit_batch:{entries:?}"); + self.deduped(op, body, |s, _| { + s.audit_fits(entries)?; + for e in entries { + s.append_audit(e).map_err(failed)?; + } + Ok(Answer::Done) + }) + .map(drop) + } + + pub(crate) fn v3_window_caps(&self, op: OpId, caps: &[Cap<'_>]) -> Result<(), CapsRefused> { + let body = format!("window_caps:{caps:?}"); + let mut inner = self.v3.lock(); + match inner.seen(op, &body) { + Seen::Replay(_) => return Ok(()), + Seen::Conflict => return Err(CapsRefused::Conflict), + Seen::New => {} + } + // Atomic per push: find the first conflict before applying any cap. + let mut pushed: HashMap = HashMap::new(); + for (index, c) in caps.iter().enumerate() { + let slot = Slot::of(&c.key); + let stored = pushed.get(&slot).or_else(|| inner.caps.get(&slot)).copied(); + match stored { + Some((cap, gen)) if gen == c.config_gen && cap != c.cap => { + return Err(CapsRefused::CapConflict { index }); + } + Some((_, gen)) if gen >= c.config_gen => {} + _ => { + pushed.insert(slot, (c.cap, c.config_gen)); + } + } + } + inner.caps.extend(pushed); + inner.record(self.now(), op, body, Answer::Done); + Ok(()) + } +} + +impl StoreSlots for MemoryStore { + const TAIL: Tail = Tail { + ephemeral: true, + durable_plane: false, + fork_refusal: true, + }; + + fn validate(_settings: &[u8]) -> Result<(), String> { + Ok(()) + } + + fn open(_settings: &[u8], _host: Option) -> Result { + Ok(Self::new()) + } + + fn add_usage_op( + &self, + _: &mut Op<'_>, + op: OpId, + bucket: &str, + window_start: u64, + delta: &UsageDelta, + ) -> Step> { + Step::Ready(self.v3_add_usage_op(op, bucket, window_start, delta)) + } + + fn add_metering_op( + &self, + _: &mut Op<'_>, + op: OpId, + delta: &MeteringDelta, + ) -> Step> { + Step::Ready(self.v3_add_metering_op(op, delta)) + } + + fn append_audit_op(&self, _: &mut Op<'_>, op: OpId, entry: &AuditRecord) -> Step> { + Step::Ready(self.v3_append_audit_op(op, entry)) + } + + fn append_plane_record_op( + &self, + _: &mut Op<'_>, + op: OpId, + record: PlaneRecordRef<'_>, + ) -> Step> { + Step::Ready(self.v3_append_plane_record_op(op, record)) + } + + fn append_batch( + &self, + _: &mut Op<'_>, + op: OpId, + stream: &str, + records: &[RecordBytes], + ) -> Step> { + Step::Ready(self.v3_append_batch(op, stream, records)) + } + + fn heads(&self, _: &mut Op<'_>) -> Step, String>> { + Step::Ready(self.v3_heads()) + } + + fn session_put( + &self, + _: &mut Op<'_>, + session: u64, + node: &str, + principal: &str, + ) -> Step> { + Step::Ready(self.v3_session_put(session, node, principal)) + } + + fn session_remove(&self, _: &mut Op<'_>, session: u64) -> Step> { + Step::Ready(self.v3_session_remove(session)) + } + + fn sessions_for( + &self, + _: &mut Op<'_>, + principal: &str, + ) -> Step, String>> { + Step::Ready(self.v3_sessions_for(principal)) + } + + fn record_put( + &self, + _: &mut Op<'_>, + schema: &str, + key: &[u8], + value: &[u8], + ) -> Step> { + Step::Ready(self.v3_record_put(schema, key, value)) + } + + fn record_get( + &self, + _: &mut Op<'_>, + schema: &str, + key: &[u8], + ) -> Step, String>> { + Step::Ready(self.v3_record_get(schema, key)) + } + + fn record_scan( + &self, + _: &mut Op<'_>, + schema: &str, + prefix: &[u8], + limit: u32, + ) -> Step> { + Step::Ready(self.v3_record_scan(schema, prefix, limit)) + } + + fn reserve<'c>( + &self, + _: &mut Op<'_>, + op: OpId, + epoch: u64, + cells: impl Iterator> + Clone, + grants: &mut impl Extend, + ) -> Step> { + Step::Ready(self.v3_reserve(op, epoch, cells, grants)) + } + + fn slice_release( + &self, + _: &mut Op<'_>, + op: OpId, + epoch: u64, + items: impl Iterator + Clone, + released: &mut impl Extend, + ) -> Step> { + Step::Ready(self.v3_slice_release(op, epoch, items, released)) + } + + fn add_usage_batch( + &self, + _: &mut Op<'_>, + op: OpId, + cells: &[(&str, u64, UsageDelta)], + ) -> Step> { + Step::Ready(self.v3_add_usage_batch(op, cells)) + } + + fn add_metering_batch( + &self, + _: &mut Op<'_>, + op: OpId, + deltas: &[MeteringDelta], + ) -> Step> { + Step::Ready(self.v3_add_metering_batch(op, deltas)) + } + + fn append_audit_batch( + &self, + _: &mut Op<'_>, + op: OpId, + entries: &[AuditRecord], + ) -> Step> { + Step::Ready(self.v3_append_audit_batch(op, entries)) + } + + fn window_caps( + &self, + _: &mut Op<'_>, + op: OpId, + caps: &[Cap<'_>], + ) -> Step> { + Step::Ready(self.v3_window_caps(op, caps)) + } + + fn put_key(&self, _: &mut Op<'_>, key: &VirtualKey) -> Step> { + Step::Ready(RecordStore::put_key(self, key)) + } + + fn get_key(&self, _: &mut Op<'_>, id: &str) -> Step>> { + Step::Ready(RecordStore::get_key(self, id)) + } + + fn list_keys(&self, _: &mut Op<'_>) -> Step>> { + Step::Ready(RecordStore::list_keys(self)) + } + + fn delete_key(&self, _: &mut Op<'_>, id: &str) -> Step> { + Step::Ready(RecordStore::delete_key(self, id)) + } + + fn scrub_key(&self, _: &mut Op<'_>, id: &str) -> Step> { + Step::Ready(RecordStore::scrub_key(self, id)) + } + + fn list_keys_since( + &self, + _: &mut Op<'_>, + since: u64, + ) -> Step>> { + Step::Ready(RecordStore::list_keys_since(self, since)) + } + + fn get_usage( + &self, + _: &mut Op<'_>, + bucket_id: &str, + window_start: u64, + ) -> Step> { + Step::Ready(RecordStore::get_usage(self, bucket_id, window_start)) + } + + fn put_usage( + &self, + _: &mut Op<'_>, + bucket_id: &str, + window_start: u64, + ledger: &UsageLedger, + ) -> Step> { + Step::Ready(RecordStore::put_usage( + self, + bucket_id, + window_start, + ledger, + )) + } + + fn list_metering( + &self, + _: &mut Op<'_>, + bucket: u64, + ) -> Step>> { + Step::Ready(RecordStore::list_metering(self, bucket)) + } + + fn purge_windows_before(&self, _: &mut Op<'_>, before: u64) -> Step> { + Step::Ready(RecordStore::purge_windows_before(self, before)) + } + + fn purge_metering_before(&self, _: &mut Op<'_>, bucket: &str) -> Step> { + Step::Ready(RecordStore::purge_metering_before(self, bucket)) + } + + fn put_credential( + &self, + _: &mut Op<'_>, + secret: &CredentialSecret, + ) -> Step> { + Step::Ready(RecordStore::put_credential(self, secret)) + } + + fn put_key_with_credential( + &self, + _: &mut Op<'_>, + key: &VirtualKey, + secret: &CredentialSecret, + ) -> Step> { + Step::Ready(RecordStore::put_key_with_credential(self, key, secret)) + } + + fn list_credentials( + &self, + _: &mut Op<'_>, + key_id: &str, + ) -> Step>> { + Step::Ready(RecordStore::list_credentials(self, key_id)) + } + + fn lookup_credential_secret( + &self, + _: &mut Op<'_>, + kind: &str, + public_id: &str, + ) -> Step>> { + Step::Ready(RecordStore::lookup_credential_secret(self, kind, public_id)) + } + + fn revoke_credential( + &self, + _: &mut Op<'_>, + id: &str, + reason: &str, + ) -> Step> { + Step::Ready(RecordStore::revoke_credential(self, id, reason)) + } + + fn list_credentials_since( + &self, + _: &mut Op<'_>, + since: u64, + ) -> Step>> { + Step::Ready(RecordStore::list_credentials_since(self, since)) + } + + fn list_audit(&self, _: &mut Op<'_>) -> Step>> { + Step::Ready(RecordStore::list_audit(self)) + } + + fn add_denylist(&self, _: &mut Op<'_>, sub: &str, reason: &str) -> Step> { + Step::Ready(RecordStore::add_denylist(self, sub, reason)) + } + + fn list_denylist(&self, _: &mut Op<'_>) -> Step>> { + Step::Ready(RecordStore::list_denylist(self)) + } + + fn list_audit_tail( + &self, + _: &mut Op<'_>, + limit: u64, + ) -> Step>> { + Step::Ready(RecordStore::list_audit_tail(self, limit)) + } + + fn upsert_plane_record( + &self, + _: &mut Op<'_>, + record: PlaneRecordRef<'_>, + ) -> Step> { + Step::Ready(RecordStore::upsert_plane_record(self, record)) + } + + fn get_plane_record( + &self, + _: &mut Op<'_>, + kind: &str, + id: &str, + ) -> Step>>> { + Step::Ready(RecordStore::get_plane_record(self, kind, id)) + } + + fn list_plane_records( + &self, + _: &mut Op<'_>, + kind: &str, + selector: &PlaneSelector<'_>, + ) -> Step>>> { + Step::Ready(RecordStore::list_plane_records(self, kind, selector)) + } + + fn list_plane_record_parents( + &self, + _: &mut Op<'_>, + kind: &str, + ) -> Step>> { + Step::Ready(RecordStore::list_plane_record_parents(self, kind)) + } + + fn purge_plane_records_before( + &self, + _: &mut Op<'_>, + kind: &str, + before: u64, + ) -> Step> { + Step::Ready(RecordStore::purge_plane_records_before(self, kind, before)) + } + + fn delete_plane_record( + &self, + _: &mut Op<'_>, + kind: &str, + id: &str, + ) -> Step> { + Step::Ready(RecordStore::delete_plane_record(self, kind, id)) + } + + fn redeem_plane_token( + &self, + _: &mut Op<'_>, + kind: &str, + token: &str, + expires_at: u64, + now: u64, + ) -> Step> { + Step::Ready(RecordStore::redeem_plane_token( + self, kind, token, expires_at, now, + )) + } + + fn plane_token_live( + &self, + _: &mut Op<'_>, + kind: &str, + token: &str, + expires_at: u64, + now: u64, + ) -> Step> { + Step::Ready(RecordStore::plane_token_live( + self, kind, token, expires_at, now, + )) + } +} diff --git a/crates/busbar-kernel/src/tests/door_op_id_tests.rs b/crates/busbar-kernel/src/tests/door_op_id_tests.rs index 0c03490291..357abad6f3 100644 --- a/crates/busbar-kernel/src/tests/door_op_id_tests.rs +++ b/crates/busbar-kernel/src/tests/door_op_id_tests.rs @@ -5,9 +5,9 @@ //! new boot is never answered as the replay of an earlier boot's write. use super::*; +use crate::test_support::store_double::MemoryStore; use busbar_contract::abi::sdk::store::{Op, Step, StoreSlots}; use busbar_contract::records::{RecordStore, UsageDelta}; -use fixture_store::MemoryStore; fn cells() -> [(&'static str, u64, UsageDelta); 1] { [( diff --git a/crates/busbar-kernel/src/tests/net_guard_fetch_tests.rs b/crates/busbar-kernel/src/tests/net_guard_fetch_tests.rs deleted file mode 100644 index 12a3e04948..0000000000 --- a/crates/busbar-kernel/src/tests/net_guard_fetch_tests.rs +++ /dev/null @@ -1,556 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// Copyright (C) 2026 Busbar Inc and contributors - -//! THE ONE GUARDED FETCH: resolve-then-pin, the address judgement, the redirect policy and the -//! caps, tested where they now live rather than once per plane — and driven through -//! `busbar_kernel::net_guard`, the shim, so this is also the statement that the shim still reaches -//! the one judge in `busbar_kernel_egress::trust::net` rather than a re-grown local copy. -//! -//! The rebinding cases are the reason this file exists. They are driven through a SCRIPTED RESOLVER -//! that answers differently on the second lookup, because that is the only way to state the claim -//! the guard actually makes: the name is resolved EXACTLY ONCE and the socket goes to the address -//! that was judged. A guard tested against a resolver that answers the same thing twice cannot tell -//! a resolve-then-pin from a check-then-re-resolve — both pass — which is precisely the mistake -//! that looks right in review. - -use super::{ - default_port, judge_address, judge_addresses, judge_host_name, judge_scheme, pin_answer, - refuse_hop_overflow, refuse_oversized_body, refuse_redirect, resolve_and_pin, split_url, - AddressRefusal, GuardPolicy, Resolver, -}; -use std::cell::RefCell; -use std::net::IpAddr; - -const PUBLIC: &str = "93.184.216.34"; -const PUBLIC_2: &str = "93.184.216.35"; - -fn ip(s: &str) -> IpAddr { - s.parse().expect("a test address must parse") -} - -fn strict() -> GuardPolicy { - GuardPolicy::default() -} - -fn private_ok() -> GuardPolicy { - GuardPolicy { - allow_private: true, - ..GuardPolicy::default() - } -} - -/// A resolver that answers a SCRIPT: the first lookup gets one answer, every later lookup gets the -/// next. It also records what it was asked, so "the guard resolved exactly once" is an assertion -/// about a number rather than about intent. -struct ScriptedResolver { - answers: RefCell, String>>>, - asked: RefCell>, -} - -impl ScriptedResolver { - fn new(answers: Vec, String>>) -> Self { - Self { - answers: RefCell::new(answers), - asked: RefCell::new(Vec::new()), - } - } - fn asked(&self) -> usize { - self.asked.borrow().len() - } -} - -impl Resolver for ScriptedResolver { - fn resolve(&self, host: &str) -> Result, String> { - self.asked.borrow_mut().push(host.to_string()); - let mut answers = self.answers.borrow_mut(); - if answers.len() > 1 { - answers.remove(0) - } else { - answers - .first() - .cloned() - .unwrap_or_else(|| Err("the script is exhausted".to_string())) - } - } -} - -/// A resolver that PANICS. Nothing refusable from the URL alone may reach it: a case that needed a -/// lookup would be a case where the guard depends on what the attacker's nameserver says. -struct NeverAsked; -impl Resolver for NeverAsked { - fn resolve(&self, host: &str) -> Result, String> { - panic!("the guard resolved `{host}`, which it must refuse structurally"); - } -} - -// ══ THE REBINDING PROOFS ═════════════════════════════════════════════════════════════════════════ - -/// **THE DNS-REBINDING CASE.** The name answers a PUBLIC address on the first lookup and a -/// LOOPBACK address on the second. A guard that checks the name and then lets the client resolve -/// again connects to the second answer; a guard that resolves once and pins connects to the first. -/// -/// The assertion is on BOTH halves, and both are needed: the pinned address must be the judged one, -/// AND the resolver must have been asked exactly once. Asserting only the address would pass -/// against a guard that resolved twice and happened to be handed the good answer first; asserting -/// only the count would pass against a guard that resolved once and pinned the wrong element. -#[test] -fn a_name_that_answers_a_private_address_on_the_second_lookup_never_gets_a_second_lookup() { - let r = ScriptedResolver::new(vec![Ok(vec![ip(PUBLIC)]), Ok(vec![ip("127.0.0.1")])]); - let target = resolve_and_pin("rebind.example", 443, true, &r, strict()) - .expect("the first answer is public and admissible"); - assert_eq!( - target.addr(), - ip(PUBLIC), - "the pin must carry the address that was JUDGED, not one a later lookup would return" - ); - assert_eq!( - r.asked(), - 1, - "the name must be resolved EXACTLY ONCE; a second lookup is the window a rebind wins in" - ); - assert_eq!(target.host(), "rebind.example", "the name is kept for SNI"); - assert_eq!(target.socket_addr(), "93.184.216.34:443".parse().unwrap()); -} - -/// The other order, which is the one an attacker actually serves: the FIRST answer is already -/// hostile. There is no "and then it rebinds" to reach, because the fetch never happens. -#[test] -fn a_name_that_answers_the_metadata_address_first_is_refused_outright() { - let r = ScriptedResolver::new(vec![Ok(vec![ip("169.254.169.254")]), Ok(vec![ip(PUBLIC)])]); - let err = resolve_and_pin("rebind.example", 443, true, &r, strict()) - .expect_err("an IMDS answer must refuse"); - assert_eq!( - err, - AddressRefusal::CloudMetadataAddress { - host: "rebind.example".to_string(), - addr: ip("169.254.169.254"), - } - ); -} - -/// A MIXED ANSWER IS A HOSTILE ANSWER. One reply carrying a public address and a loopback one is -/// refused whole rather than filtered to the address that happens to pass — otherwise the same name -/// is sometimes fine and sometimes not, decided by an ordering the upstream chooses. -#[test] -fn a_mixed_answer_is_refused_whole_in_either_order() { - let forward = [ip(PUBLIC), ip("127.0.0.1")]; - let err = judge_addresses("mixed.example", &forward, strict()) - .expect_err("a loopback address in the answer must refuse the resolution"); - assert_eq!( - err, - AddressRefusal::InternalAddress { - host: "mixed.example".to_string(), - addr: ip("127.0.0.1"), - } - ); - let reversed = [ip("127.0.0.1"), ip(PUBLIC)]; - assert!( - judge_addresses("mixed.example", &reversed, strict()).is_err(), - "order must not decide the verdict" - ); - // The CONTROL: an all-public answer passes, so the two above are not passing because everything - // is refused. - assert!(judge_addresses("mixed.example", &[ip(PUBLIC), ip(PUBLIC_2)], strict()).is_ok()); -} - -// ══ THE ADDRESS JUDGEMENT ════════════════════════════════════════════════════════════════════════ - -#[test] -fn every_internal_range_is_refused() { - let internal = [ - "127.0.0.1", - "10.0.0.1", - "172.16.0.1", - "192.168.1.1", - "169.254.1.1", - "100.64.0.1", - "0.0.0.0", - "0.1.2.3", - "255.255.255.255", - "224.0.0.1", - "198.18.0.1", - "192.0.2.1", - "::1", - "fc00::1", - "fe80::1", - "::", - // An IPv4-mapped loopback: the v4 ruleset must not be bypassable via a AAAA record. - "::ffff:127.0.0.1", - ]; - assert_eq!(internal.len(), 17, "the internal-range set must not shrink"); - for a in internal { - assert!( - judge_address("h", ip(a), strict()).is_err(), - "{a} must be refused" - ); - } - // The CONTROL. - assert!(judge_address("h", ip(PUBLIC), strict()).is_ok()); -} - -/// CLOUD METADATA IS REFUSED EVEN UNDER `allow_private`, and it is refused AS METADATA — the arm -/// runs BEFORE the flag is consulted. Merging it into the internal-range arm would make -/// `allow_private` a config flag that hands out cloud credentials. -#[test] -fn cloud_metadata_is_refused_unconditionally_and_as_metadata() { - let metadata = [ - "169.254.169.254", - "169.254.170.2", - "100.100.100.200", - "168.63.129.16", - "192.0.0.192", - "fd00:ec2::254", - // The IPv4-COMPATIBLE and IPv4-MAPPED spellings of IMDS: neither matches a v6 range, and a - // guard that unwrapped only the mapped form connected to the compatible one. - "::169.254.169.254", - "::ffff:169.254.169.254", - ]; - assert_eq!(metadata.len(), 8, "the metadata set must not shrink"); - for a in metadata { - for policy in [strict(), private_ok()] { - let err = judge_address("meta", ip(a), policy) - .expect_err("metadata is refused under every policy"); - assert!( - matches!(err, AddressRefusal::CloudMetadataAddress { .. }), - "{a} must be refused AS METADATA, not merely as internal: {err:?}" - ); - } - } - // And a private address that is NOT metadata IS permitted under `allow_private`, so the - // unconditional refusal above is specific to metadata rather than to everything. - assert!(judge_address("h", ip("10.0.0.1"), private_ok()).is_ok()); -} - -/// THE WHOLE LINK-LOCAL /16 IS METADATA, not just the five literals a list can name. -/// -/// Clouds put their instance-metadata service anywhere inside `169.254.0.0/16` (Tencent answers on -/// `169.254.0.23`, AWS's IPv6-era ECS endpoint on `169.254.170.3`, the IMDS v6 alias on -/// `169.254.169.253`), and nothing legitimate runs on link-local at all. An address-side predicate -/// that enumerates literals leaves every other link-local address to the internal-range arm, which -/// `allow_private: true` switches off — so the operator flag that says "our upstream is on the -/// internal network" would pin and dial an unlisted metadata endpoint. -#[test] -fn unlisted_link_local_metadata_is_refused_as_metadata_under_allow_private() { - let unlisted = [ - ("Tencent IMDS", "169.254.0.23"), - ("IMDS v6-alias endpoint", "169.254.169.253"), - ("ECS task metadata (v6-era)", "169.254.170.3"), - // The IPv4-COMPATIBLE spelling reaches the same target through `to_ipv4()`. - ("IPv4-COMPATIBLE Tencent", "::169.254.0.23"), - ("IPv4-MAPPED Tencent", "::ffff:169.254.0.23"), - ]; - for (what, a) in unlisted { - for policy in [strict(), private_ok()] { - let err = judge_address("meta", ip(a), policy) - .expect_err("link-local metadata is refused under every policy"); - assert!( - matches!(err, AddressRefusal::CloudMetadataAddress { .. }), - "{what} ({a}) must be refused AS METADATA, not merely as internal: {err:?}" - ); - } - } -} - -/// NAT64 / RFC 6052 END TO END: a DNS64-answered destination must be refused as metadata, not -/// pinned as an ordinary public v6 address, running through the SAME resolve-then-pin guard every -/// other rebinding case in this file is driven through. -/// -/// A DNS64 resolver on an IPv6-only network answers a AAAA query with the NAT64 synthesis of the -/// queried name's IPv4 address. `64:ff9b:1:fffe::a9fe:a9fe` is the RFC 8215 local-use synthesis -/// (with a non-zero middle, the shape RFC 8215 Section 6's own worked example uses) of the IMDS -/// target `169.254.169.254`; it matches no v6 range and no `to_ipv4()` form, so a guard unwrapping -/// only `to_ipv4()` would pin it as an ordinary public address. -#[test] -fn nat64_dns64_synthesis_of_imds_is_refused_even_under_allow_private() { - let r = ScriptedResolver::new(vec![Ok(vec![ip("64:ff9b:1:fffe::a9fe:a9fe")])]); - let err = resolve_and_pin("dns64.example", 443, true, &r, private_ok()).expect_err( - "the RFC 8215 local-use NAT64 synthesis of the IMDS target must be refused even under \ - allow_private", - ); - assert!( - matches!(err, AddressRefusal::CloudMetadataAddress { .. }), - "must be refused AS METADATA, not merely pinned as an ordinary public address: {err:?}" - ); -} - -// ══ THE STRUCTURAL REFUSALS ══════════════════════════════════════════════════════════════════════ - -/// The metadata NAMES are refused before any resolver is consulted, and `allow_private` does not -/// speak for them. The `localhost` family is the population it DOES speak for, and the split -/// between the two lists is the whole point of having two arms. -#[test] -fn the_metadata_names_are_refused_under_every_policy_and_localhost_only_by_default() { - for name in [ - "metadata.google.internal", - "metadata.google.internal.", - "METADATA.GOOGLE.INTERNAL", - "metadata.internal", - ] { - for policy in [strict(), private_ok()] { - assert_eq!( - judge_host_name(name, policy), - Err(AddressRefusal::MetadataName(name.to_string())), - "`{name}` is a cloud-metadata name and `allow_private` may not reach it" - ); - } - } - for name in ["localhost", "localhost.", "api.localhost"] { - assert!( - matches!( - judge_host_name(name, strict()), - Err(AddressRefusal::LoopbackName(_)) - ), - "`{name}` is the loopback family and is refused by default" - ); - assert!( - judge_host_name(name, private_ok()).is_ok(), - "`{name}` is what `allow_private` is for" - ); - } - assert!(judge_host_name("agent.vendor", strict()).is_ok()); -} - -#[test] -fn alternate_ipv4_encodings_are_refused_before_the_resolver_sees_them() { - for host in ["2130706433", "0x7f000001", "017700000001", "127.1"] { - for policy in [strict(), private_ok()] { - assert_eq!( - judge_host_name(host, policy), - Err(AddressRefusal::ObfuscatedHost(host.to_string())), - "`{host}` is an encoding the resolver expands and the check cannot read" - ); - } - } -} - -#[test] -fn a_literal_is_judged_and_pinned_without_a_resolver() { - let t = resolve_and_pin(PUBLIC, 8443, true, &NeverAsked, strict()) - .expect("a public literal is its own answer"); - assert_eq!(t.addr(), ip(PUBLIC)); - assert_eq!(t.port(), 8443); - assert!(t.is_https()); - - assert!(matches!( - resolve_and_pin("127.0.0.1", 9000, true, &NeverAsked, strict()), - Err(AddressRefusal::InternalAddress { .. }) - )); - let t = resolve_and_pin("127.0.0.1", 9000, false, &NeverAsked, private_ok()) - .expect("an opted-in private literal pins"); - assert_eq!(t.socket_addr(), "127.0.0.1:9000".parse().unwrap()); - assert!(!t.is_https()); -} - -// ══ RESOLUTION FAILURE IS NOT ABSENCE ════════════════════════════════════════════════════════════ - -#[test] -fn a_resolution_failure_and_an_empty_answer_are_different_facts() { - let failing = ScriptedResolver::new(vec![Err("NXDOMAIN".to_string())]); - assert_eq!( - resolve_and_pin("a.example", 443, true, &failing, strict()), - Err(AddressRefusal::Unresolvable { - host: "a.example".to_string(), - reason: "NXDOMAIN".to_string(), - }) - ); - let empty = ScriptedResolver::new(vec![Ok(vec![])]); - assert_eq!( - resolve_and_pin("a.example", 443, true, &empty, strict()), - Err(AddressRefusal::NoAddresses("a.example".to_string())), - "an empty answer has nothing to connect to and nothing to have judged" - ); -} - -// ══ THE STRICT RECOGNISER ════════════════════════════════════════════════════════════════════════ - -#[test] -fn only_http_and_https_are_recognised() { - let banned = [ - "file:///etc/passwd", - "gopher://x/", - "smb://host/share", - "ftp://host/", - "data:text/plain,hi", - "ws://host/", - "/no-scheme", - "host.example/rpc", - ]; - assert_eq!(banned.len(), 8, "the banned-scheme set must not shrink"); - for url in banned { - assert!( - matches!(split_url(url), Err(AddressRefusal::Scheme { .. })), - "`{url}` must be refused on its scheme" - ); - } - assert!(split_url("https://ok.example/rpc").is_ok()); -} - -#[test] -fn userinfo_is_refused_rather_than_stripped() { - assert!(matches!( - split_url("https://evil.test@good.example/rpc"), - Err(AddressRefusal::NoHost(_)) - )); -} - -/// A refusal names the URL, and a URL's authority is where a password goes. -/// -/// The refusal text is written into a card and a log, both read by more people than a config is and -/// kept for longer. Repeating the credential there would make the refusal the one place the secret -/// is written down twice — so the authority's userinfo is replaced by a marker, and the host, which -/// is the whole diagnosis, stays. -#[test] -fn a_refusal_never_repeats_the_credential_in_the_authority() { - let refusals = [ - split_url("https://svc:hunter2@good.example/rpc").expect_err("userinfo is refused"), - split_url("ftp://svc:hunter2@good.example/x").expect_err("the scheme is refused"), - judge_scheme("http://svc:hunter2@good.example/x", false, strict()) - .expect_err("plaintext is refused"), - refuse_oversized_body("https://svc:hunter2@good.example/x", 1 << 30, strict()) - .expect_err("the body is over the ceiling"), - ]; - for refusal in refusals { - let text = refusal.to_string(); - assert!( - !text.contains("hunter2") && !text.contains("svc"), - "the refusal repeated the credential: {text}" - ); - assert!( - text.contains("good.example"), - "the refusal must still name the host it is about: {text}" - ); - } - - // A `@` in the path is not a credential and is left as the operator wrote it. - let path_at = split_url("ftp://good.example/mail@archive").expect_err("the scheme is refused"); - assert!(path_at.to_string().contains("mail@archive")); -} - -#[test] -fn default_ports_are_derived_from_the_scheme_and_ipv6_comes_back_unbracketed() { - let (https, host, port, path) = split_url("https://a.example/rpc").unwrap(); - assert!(https && host == "a.example" && port == 443 && path == "/rpc"); - let (https, _, port, path) = split_url("http://a.internal").unwrap(); - assert!(!https && port == 80 && path == "/"); - let (_, host, port, _) = split_url("https://[::1]:9443/x").unwrap(); - assert_eq!((host.as_str(), port), ("::1", 9443)); - assert_eq!((default_port(true), default_port(false)), (443, 80)); -} - -#[test] -fn plaintext_is_refused_unless_the_policy_admits_it() { - assert!(matches!( - judge_scheme("http://public.example/x", false, strict()), - Err(AddressRefusal::Plaintext { .. }) - )); - assert!(judge_scheme("https://public.example/x", true, strict()).is_ok()); - // Either knob admits it, because opting an upstream into private addressing at all is one - // decision rather than two. - assert!(judge_scheme("http://x.internal/x", false, private_ok()).is_ok()); - assert!(judge_scheme( - "http://public.example/x", - false, - GuardPolicy { - allow_plaintext: true, - ..GuardPolicy::default() - } - ) - .is_ok()); -} - -// ══ REDIRECTS, HOPS AND CAPS ═════════════════════════════════════════════════════════════════════ - -#[test] -fn a_redirect_is_refused_and_names_its_target() { - for status in [301u16, 302, 303, 307, 308] { - assert_eq!( - refuse_redirect(status, Some("http://169.254.169.254/latest/meta-data/")), - Err(AddressRefusal::Redirect { - status, - location: "http://169.254.169.254/latest/meta-data/".to_string(), - }) - ); - } - // Non-3xx passes, so the check is about redirects rather than about everything. - for ok in [200u16, 404, 500] { - assert!(refuse_redirect(ok, None).is_ok()); - } - assert_eq!( - refuse_redirect(302, None), - Err(AddressRefusal::Redirect { - status: 302, - location: "".to_string(), - }) - ); -} - -#[test] -fn the_hop_bound_refuses_at_the_limit_rather_than_past_it() { - let three = GuardPolicy { - max_redirects: 3, - ..GuardPolicy::default() - }; - for hops in 0..3u32 { - assert!(refuse_hop_overflow(hops, "https://a.example/", three).is_ok()); - } - assert_eq!( - refuse_hop_overflow(3, "https://a.example/", three), - Err(AddressRefusal::TooManyRedirects { - limit: 3, - at: "https://a.example/".to_string(), - }) - ); - // Zero redirects is a legitimate setting and means "the document must be where I said". - assert!(refuse_hop_overflow(0, "https://a.example/", GuardPolicy::default()).is_err()); -} - -#[test] -fn the_body_cap_refuses_over_the_ceiling_and_not_at_it() { - let policy = GuardPolicy { - max_body_bytes: 5 * 1024, - ..GuardPolicy::default() - }; - assert!(refuse_oversized_body("https://a.example/", 5 * 1024, policy).is_ok()); - assert_eq!( - refuse_oversized_body("https://a.example/", 5 * 1024 + 1, policy), - Err(AddressRefusal::BodyTooLarge { - url: "https://a.example/".to_string(), - bytes: 5 * 1024 + 1, - }) - ); -} - -/// The default is FAIL-CLOSED in every direction, so a caller that forgets a knob gets the strict -/// answer. A default that permitted anything would make "forgot to set it" indistinguishable from -/// "decided to allow it". -#[test] -fn the_default_policy_is_closed_in_every_direction() { - let d = GuardPolicy::default(); - assert!(!d.allow_private); - assert!(!d.allow_plaintext); - assert!(!d.plaintext_admissible()); - assert_eq!(d.max_redirects, 0); - assert_eq!(d.max_body_bytes, 64 * 1024); - assert_eq!(d.timeout, std::time::Duration::from_secs(10)); -} - -/// `pin_answer` is the one door every resolution goes through, so its own refusals are asserted -/// here rather than only through its callers. -#[test] -fn the_pin_carries_the_first_admissible_address_and_the_scheme_it_was_judged_under() { - let t = pin_answer( - "a.example", - 8443, - true, - &[ip(PUBLIC), ip(PUBLIC_2)], - strict(), - ) - .expect("an all-public answer pins"); - assert_eq!(t.addr(), ip(PUBLIC), "the resolver's own ordering is kept"); - assert_eq!(t.host(), "a.example"); - assert!(t.is_https()); - assert_eq!( - pin_answer("a.example", 443, true, &[], strict()), - Err(AddressRefusal::NoAddresses("a.example".to_string())) - ); -} diff --git a/crates/busbar-kernel/src/tests/net_guard_tests.rs b/crates/busbar-kernel/src/tests/net_guard_tests.rs deleted file mode 100644 index 38ae359859..0000000000 --- a/crates/busbar-kernel/src/tests/net_guard_tests.rs +++ /dev/null @@ -1,452 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// Copyright (C) 2026 Busbar Inc and contributors - -//! Tests for the network guard AS REACHED THROUGH `busbar_kernel::net_guard`. -//! -//! The guard itself is `busbar_kernel_egress::trust::net` and is tested there too. This suite is not -//! a second copy of that one: it is driven through the SHIM, so it is also the statement that the -//! shim still resolves to the one judge. A re-grown local definition behind `crate::net_guard` -//! would have to pass every row below to go unnoticed — including -//! `the_dialing_guard_and_the_config_guard_know_the_same_metadata_names`, which is the bug that was -//! actually live. - -use super::*; -// The address type these assertions name. It used to arrive through `use super::*` from -// the guard's own imports; the shim imports only what IT needs, so a suite that names a type -// says so itself. -use std::net::Ipv4Addr; - -#[test] -fn is_cgnat_shared_v4_covers_rfc6598_only() { - // 100.64.0.0/10 = first octet 100, second octet's top two bits == 01 (i.e. 64..=127). - assert!(is_cgnat_shared_v4(&Ipv4Addr::new(100, 64, 0, 0))); - assert!(is_cgnat_shared_v4(&Ipv4Addr::new(100, 100, 100, 200))); // Alibaba metadata - assert!(is_cgnat_shared_v4(&Ipv4Addr::new(100, 127, 255, 255))); - // Outside the /10: second octet below 64 or above 127, or different first octet. - assert!(!is_cgnat_shared_v4(&Ipv4Addr::new(100, 63, 255, 255))); - assert!(!is_cgnat_shared_v4(&Ipv4Addr::new(100, 128, 0, 0))); - assert!(!is_cgnat_shared_v4(&Ipv4Addr::new(99, 64, 0, 0))); - assert!(!is_cgnat_shared_v4(&Ipv4Addr::new(8, 8, 8, 8))); -} - -#[test] -fn is_unique_local_v6_covers_fc00_slash_7() { - // fc00::/7 — first 7 bits 1111110, so fc00.. and fd00.. are in-range. - assert!(is_unique_local_v6(&"fc00::1".parse().unwrap())); - assert!(is_unique_local_v6(&"fd00:ec2::254".parse().unwrap())); // EC2 IMDSv6 - assert!(is_unique_local_v6(&"fdff:ffff::".parse().unwrap())); - // Outside fc00::/7. - assert!(!is_unique_local_v6(&"fe80::1".parse().unwrap())); // link-local, not ULA - assert!(!is_unique_local_v6(&"2001:db8::1".parse().unwrap())); - assert!(!is_unique_local_v6(&"::1".parse().unwrap())); -} - -#[test] -fn is_link_local_v6_covers_fe80_slash_10() { - // fe80::/10 — first 10 bits 1111111010. - assert!(is_link_local_v6(&"fe80::1".parse().unwrap())); - assert!(is_link_local_v6(&"febf:ffff::".parse().unwrap())); - // Outside fe80::/10. - assert!(!is_link_local_v6(&"fec0::1".parse().unwrap())); // site-local (deprecated), not fe80::/10 - assert!(!is_link_local_v6(&"fc00::1".parse().unwrap())); // ULA, not link-local - assert!(!is_link_local_v6(&"2001:db8::1".parse().unwrap())); -} - -#[test] -fn is_alternate_ipv4_encoding_flags_obfuscated_forms() { - assert!(is_alternate_ipv4_encoding("2130706433")); // decimal 127.0.0.1 - assert!(is_alternate_ipv4_encoding("0x7f000001")); // hex - assert!(is_alternate_ipv4_encoding("0X7F000001")); // hex, uppercase prefix - assert!(is_alternate_ipv4_encoding("017700000001")); // leading-zero octal - assert!(is_alternate_ipv4_encoding("127.1")); // short dotted - assert!(is_alternate_ipv4_encoding("10.0.1")); // short dotted - assert!(is_alternate_ipv4_encoding("0x7f.0.0.1")); // per-octet hex - assert!(is_alternate_ipv4_encoding("0177.0.0.1")); // per-octet octal - - // Canonical dotted-quads are left to the `parse::()` path, not flagged here. - assert!(!is_alternate_ipv4_encoding("127.0.0.1")); - assert!(!is_alternate_ipv4_encoding("8.8.8.8")); - // DNS names and the empty string are not alternate encodings. - assert!(!is_alternate_ipv4_encoding("api.provider.example")); - assert!(!is_alternate_ipv4_encoding("example.com")); - assert!(!is_alternate_ipv4_encoding("")); -} - -// ══ THE CLASS TEST FOR THE GUARDED-FETCH CHOKE POINT ═════════════════════════════════════════════ -// -// `ip_is_internal` is the ONE address predicate every plane's outbound guard is required to route -// through (structure-lint choke point `H-net-guard`). Its table therefore has to be the UNION of -// what every plane-local copy ever checked, because the tear-out of a copy is only safe if the -// shared predicate already covers everything that copy covered. Two of the rows below arrived here -// exactly that way — from `a2a::pushnotify`'s private copy, which checked ranges this one did not. -// -// The floor at the end is what stops the table quietly shrinking: a row deleted with the range it -// guarded is the failure mode this whole exercise exists to prevent. - -/// EVERY range a busbar guard must refuse, in one table, asserted through the shared entry point. -#[test] -fn the_shared_internal_predicate_covers_every_range_any_plane_ever_checked() { - use std::net::IpAddr; - let cases: &[(&str, &str)] = &[ - ("loopback v4 127/8", "127.0.0.1"), - ("private 10/8", "10.1.2.3"), - ("private 172.16/12", "172.16.5.5"), - ("private 192.168/16", "192.168.1.1"), - ("link-local 169.254/16", "169.254.1.1"), - ("AWS IMDS", "169.254.169.254"), - ("ECS task metadata", "169.254.170.2"), - ("Alibaba metadata (inside CGNAT)", "100.100.100.200"), - ("CGNAT 100.64/10", "100.64.0.1"), - ("Azure WireServer (a PUBLIC address)", "168.63.129.16"), - ("OCI IMDS (a PUBLIC-shaped address)", "192.0.0.192"), - ("unspecified", "0.0.0.0"), - // FROM `a2a::pushnotify`'s copy: 0.0.0.0/8 is "this network", and several stacks route the - // whole block to the local host — so `is_unspecified()` alone (which is only 0.0.0.0) left - // 0.1.2.3 reachable on every plane that used this predicate. - ("this-network 0/8", "0.1.2.3"), - // FROM `a2a::pushnotify`'s copy: 192.0.0.0/24 IETF protocol assignments (the /24 OCI's - // 192.0.0.192 sits inside) and 198.18.0.0/15 benchmarking. Neither is a legitimate - // destination and both are reachable inside some fabrics. - ("IETF protocol assignments 192.0.0/24", "192.0.0.8"), - ("benchmarking 198.18/15", "198.18.0.1"), - ("benchmarking 198.19/16", "198.19.0.1"), - ("broadcast", "255.255.255.255"), - ("multicast v4", "224.0.0.1"), - // ALL THREE DOCUMENTATION BLOCKS (RFC 5737), not just TEST-NET-1. `is_documentation()` - // covers the other two as well, and leaving them unasserted is how a floor gets written - // above the table it guards: these were the rows the count was already reserving room for. - ("documentation TEST-NET-1 192.0.2/24", "192.0.2.1"), - ("documentation TEST-NET-2 198.51.100/24", "198.51.100.7"), - ("documentation TEST-NET-3 203.0.113/24", "203.0.113.9"), - ("loopback v6", "::1"), - ("unspecified v6", "::"), - ("unique-local v6 fc00::/7", "fd00::1"), - ("link-local v6 fe80::/10", "fe80::1"), - ("multicast v6", "ff02::1"), - ("EC2 IMDSv6", "fd00:ec2::254"), - // The two embedded-v4 spellings. The COMPATIBLE one is the literal that got through a copy - // unwrapping with `to_ipv4_mapped()`; it matches no v6 range at all. - ("IPv4-MAPPED metadata", "::ffff:169.254.169.254"), - ("IPv4-COMPATIBLE metadata", "::169.254.169.254"), - ("IPv4-COMPATIBLE loopback", "::127.0.0.1"), - ]; - let mut checked = 0usize; - for (what, spelling) in cases { - let ip: IpAddr = spelling.parse().expect(what); - assert!( - ip_is_internal(&ip), - "{what} ({spelling}) must be internal to the SHARED predicate — a plane that routes \ - through it inherits this row, and a plane that does not is the drift this test exists \ - to catch" - ); - checked += 1; - } - // `checked` equals `cases.len()` by construction (one increment per row, no early `continue`), - // so the anti-shrink guard is a FLOOR on that count, not an equality that could only restate it. - assert!( - checked >= 30, - "the shared hostile table shrank; a deleted row is a range every plane silently stopped \ - guarding" - ); -} - -/// WHITESPACE AROUND A URL MUST NOT BUY A METADATA HOP. The WHATWG basic URL parser begins by -/// trimming leading and trailing C0 controls AND spaces from the input, and by deleting every ASCII -/// tab / CR / LF from anywhere inside it — so a connecting stack sees `169.254.169.254` for every -/// spelling below. Any spelling this guard reads differently from the stack that will dial it is a -/// bypass: a token endpoint POSTs client credentials to the URL verbatim, so a host the guard failed -/// to recognize as IMDS is a host that receives those credentials. -#[test] -fn whitespace_padded_metadata_urls_are_still_refused() { - for spelling in [ - "http://169.254.169.254/latest/meta-data/ ", // trailing space after the path - "http://169.254.169.254 ", // trailing space directly after the host - " http://169.254.169.254/latest/meta-data/", // leading space (would hide the scheme) - "\u{1}http://169.254.169.254/", // leading C0 control - "http://169.254.169.254/\u{1f}", // trailing C0 control - "http://169.254.169\t.254/", // interior tab, deleted by the parser - "http://169.254.169.254\r\n/", // interior CR/LF - "\t http://169.254.169.254/ \r\n", // mixed padding, both ends - ] { - assert_eq!( - ssrf_blocked_host(spelling, &[], false, &[]).as_deref(), - Some("169.254.169.254"), - "{spelling:?} is dialled as the IMDS target once the parser trims and deletes the \ - whitespace the guard must trim and delete the same way" - ); - } -} - -/// The CONTROL for the trim: whitespace INSIDE a host (not at either end of the input, and not one -/// of the three deleted bytes) is left alone, so a malformed host stays malformed rather than being -/// silently repaired into something that matches. -#[test] -fn interior_spaces_are_not_trimmed_away() { - assert_eq!( - extract_normalized_host("http://169.254.169 .254/").as_deref(), - Some("169.254.169 .254") - ); - assert_eq!( - ssrf_blocked_host("http://169.254.169 .254/", &[], false, &[]), - None - ); - assert_eq!( - extract_normalized_host(" https://api.provider.example/v1 ").as_deref(), - Some("api.provider.example") - ); -} - -/// The CONTROL. Without it a predicate that returned `true` unconditionally would pass the table -/// above, and every legitimate upstream in the fleet would be refused. -#[test] -fn the_shared_internal_predicate_admits_ordinary_public_addresses() { - use std::net::IpAddr; - for ok in [ - "93.184.216.34", - "8.8.8.8", - "1.1.1.1", - // 100.128/9 is OUTSIDE the RFC 6598 /10 and is ordinary public space. - "100.128.0.1", - // 198.20/16 is outside the 198.18/15 benchmarking block. - "198.20.0.1", - // 192.0.1.0/24 sits between the IETF-assignments /24 and the documentation /24. - "192.0.1.1", - "2606:4700:4700::1111", - "::ffff:93.184.216.34", - ] { - let ip: IpAddr = ok.parse().expect(ok); - assert!( - !ip_is_internal(&ip), - "{ok} is ordinary public space and must remain reachable" - ); - } -} - -/// CLOUD METADATA IS A SEPARATE QUESTION FROM INTERNAL, because the two carry different policies: -/// an operator may opt into internal addressing with `allow_private`, and may never opt into IMDS. -#[test] -fn cloud_metadata_is_judged_separately_and_covers_every_vendor() { - use std::net::IpAddr; - for meta in [ - "169.254.169.254", // AWS / Azure / GCP / OpenStack / DigitalOcean - "169.254.170.2", // ECS task metadata - "100.100.100.200", // Alibaba - "168.63.129.16", // Azure WireServer - "192.0.0.192", // OCI - "fd00:ec2::254", // EC2 IMDSv6 - "::ffff:169.254.169.254", - "::169.254.169.254", - ] { - let ip: IpAddr = meta.parse().expect(meta); - assert!( - ip_is_cloud_metadata(&ip), - "{meta} is a cloud-metadata endpoint and no policy flag may reach it" - ); - } - assert!(!ip_is_cloud_metadata( - &"93.184.216.34".parse::().unwrap() - )); - // An internal address that is NOT metadata: `allow_private` may reach this one. - assert!(!ip_is_cloud_metadata( - &"10.0.0.1".parse::().unwrap() - )); -} - -/// NAT64 / RFC 6052 EMBEDDING MUST BE JUDGED, not left to fall through to the v6 range checks that -/// do not cover `64:ff9b::/96` at all. -/// -/// A DNS64 resolver on an IPv6-only network answers a AAAA query with the NAT64 synthesis of the -/// queried name's IPv4 address rather than the address itself. A guard that unwraps only -/// `to_ipv4()` (IPv4-MAPPED/IPv4-COMPATIBLE) does not recognise `64:ff9b::/96` at all, so -/// `64:ff9b::a9fe:a9fe` — the IMDS target `169.254.169.254` re-encoded — matches no v6 range and -/// reads as an ordinary public v6 address. This asserts the embedding is judged in both the -/// well-known (RFC 6052) and RFC 8215 local-use forms, including the local-use form with a -/// NON-ZERO middle (RFC 8215 Section 6's own `64:ff9b:1:fffe::/96` worked example). -#[test] -fn nat64_embedded_ipv4_is_judged_by_the_shared_predicates() { - use std::net::{IpAddr, Ipv6Addr}; - - for meta in [ - "64:ff9b::a9fe:a9fe", // RFC 6052 well-known - "64:ff9b:1::a9fe:a9fe", // RFC 8215 local-use, zero-padded - "64:ff9b:1:fffe::a9fe:a9fe", // RFC 8215 local-use, non-zero middle (RFC 8215 Section 6 example) - ] { - let addr: IpAddr = meta.parse().expect(meta); - assert!( - ip_is_cloud_metadata(&addr), - "{meta} is the NAT64 synthesis of the IMDS target 169.254.169.254 and must be judged \ - metadata" - ); - assert!( - ip_is_internal(&addr), - "{meta} is also internal — every cloud-metadata address is internal" - ); - } - for internal in [ - "64:ff9b::7f00:1", // 127.0.0.1 loopback - "64:ff9b::a01:203", // 10.1.2.3 private - "64:ff9b:1::7f00:1", // local-use loopback - "64:ff9b:1:fffe::a01:203", // non-zero-padded local-use private - ] { - let addr: IpAddr = internal.parse().expect(internal); - assert!( - ip_is_internal(&addr), - "{internal} is a NAT64 embedding of an internal IPv4 target and must be internal" - ); - assert!( - !ip_is_cloud_metadata(&addr), - "{internal} is internal but not metadata" - ); - } - - // `embedded_ipv4` decodes the low 32 bits regardless of the operator-chosen local-use middle. - assert_eq!( - embedded_ipv4(&"64:ff9b:1:fffe::a9fe:a9fe".parse::().unwrap()), - Some(Ipv4Addr::new(169, 254, 169, 254)), - ); - // RFC 6052 fixes the ENTIRE well-known /96 to zero: a non-zero middle there is an ordinary - // address, not an embedding, and must NOT be unwrapped. - assert_eq!( - embedded_ipv4(&"64:ff9b::1:0:a9fe:a9fe".parse::().unwrap()), - None, - ); -} - -/// THE HOST-STRING GUARDS MUST JUDGE THE NAT64 EMBEDDING TOO, not just the address predicates. -/// -/// [`ip_is_cloud_metadata`] and [`ip_is_internal`] take an already-parsed `IpAddr` and were fixed -/// first. The three guards below take a HOST STRING and parse it themselves, and each kept its own -/// `to_ipv4()` unwrap — so `64:ff9b::a9fe:a9fe` parsed clean, matched no v6 range, and read as an -/// ordinary public address. That gap was live after the address-level fix landed, which is exactly -/// why this test exists separately from -/// `nat64_embedded_ipv4_is_judged_by_the_shared_predicates`: fixing the predicate does NOT fix the -/// callers that never call it. -/// -/// [`ssrf_blocked_host`] is the pre-flight guard for OAuth token-endpoint URLs and for MCP -/// tool-call ARGUMENT hosts — attacker-influenced input on the classic SSRF injection path — so a -/// bypass here is reachable without any DNS answer at all. -#[test] -fn nat64_embedded_ipv4_is_judged_by_the_host_string_guards() { - // Every spelling of the IMDS target 169.254.169.254 that a caller can hand us. - for meta in [ - "169.254.169.254", // bare v4 - "::ffff:169.254.169.254", // v4-mapped - "64:ff9b::a9fe:a9fe", // NAT64 well-known (RFC 6052) - "64:ff9b:1::a9fe:a9fe", // NAT64 local-use (RFC 8215), zero middle - "64:ff9b:1:fffe::a9fe:a9fe", // NAT64 local-use, non-zero middle - ] { - let url = format!("http://[{meta}]/latest/meta-data/"); - let url = if meta.contains(':') { - url - } else { - format!("http://{meta}/latest/meta-data/") - }; - assert!( - ssrf_blocked_host(&url, &[], false, &[]).is_some(), - "{meta} reaches the cloud-metadata service and must be refused pre-flight; \ - a NAT64 spelling is the same target as the bare form" - ); - } - - // The private/loopback host predicate must see through the embedding as well. - for internal in [ - "64:ff9b::7f00:1", // 127.0.0.1 - "64:ff9b::a01:203", // 10.1.2.3 - "64:ff9b:1:fffe::c0a8:1", // 192.168.0.1 via local-use NAT64 - ] { - assert!( - host_is_private_or_loopback(internal), - "{internal} is a NAT64 embedding of an internal target and must read as internal" - ); - } - - // An operator denylist entry written in the bare v4 form must also catch the NAT64 spelling — - // otherwise the denylist is trivially evaded by re-encoding the same address. Asked through the - // operator's OWN door rather than through the canonicalizer, and asked on an address the - // hardcoded denylist does NOT hold (`10.99.99.99` is a legitimate upstream by default), so the - // refusal can only be coming from the entry: the control below shows the same host passing when - // the list is empty. - const NAT64_OF_10_99_99_99: &str = "https://[64:ff9b::a63:6363]/"; - assert!( - ssrf_blocked_host( - NAT64_OF_10_99_99_99, - &[], - false, - &["10.99.99.99".to_string()] - ) - .is_some(), - "a denylist entry naming the v4 address must match its NAT64 embedding" - ); - assert!( - ssrf_blocked_host(NAT64_OF_10_99_99_99, &[], false, &[]).is_none(), - "the control: without the operator entry this host is an ordinary private upstream, so the \ - assertion above is about the entry and not about the hardcoded denylist" - ); - - // Public addresses are unaffected: 8.8.8.8 embedded in NAT64 is still public. - assert!( - !host_is_private_or_loopback("64:ff9b::808:808"), - "64:ff9b::808:808 embeds public 8.8.8.8 and must NOT read as internal" - ); -} - -/// THE DIALING GUARD AND THE CONFIG GUARD MUST KNOW THE SAME METADATA NAMES. -/// -/// They did not. `ssrf_blocked_host` kept a SIX-name list private to its own body while -/// `judge_host_name` — the arm the resolve-then-pin dialing path consults — read a TWO-name -/// module const. Config validation refused `metadata.tencentyun.com`; the socket did not. -#[test] -fn the_dialing_guard_and_the_config_guard_know_the_same_metadata_names() { - // Every name `ssrf_blocked_host` (config validation) refuses must ALSO be refused by - // `judge_host_name`, which is the arm the resolve-then-pin dialing path actually consults. - for name in [ - "metadata.google.internal", - "metadata.internal", - "metadata.tencentyun.com", - "metadata.platformequinix.com", - "instance-data", - "instance-data.ec2.internal", - ] { - // Config-validation view. - let cfg = ssrf_blocked_host(&format!("https://{name}/"), &[], false, &[]); - // Dialing view — the one a socket is opened behind. - let dial = judge_host_name(name, GuardPolicy::default()); - assert!( - cfg.is_some(), - "{name}: config guard should refuse (this is the 6-name list)" - ); - assert!( - dial.is_err(), - "{name}: DIALING guard did NOT refuse — config refuses it but the dial path allows it" - ); - } -} - -/// The NAME arm is the ONLY defence for a metadata host that answers on a PUBLIC address. -/// -/// `ip_is_cloud_metadata` covers link-local /16 plus three literals plus IMDSv6. A metadata -/// endpoint reachable at a globally-routable address matches NONE of them, so if the name is not -/// in `METADATA_HOSTS` the resolve-then-pin path dials it. -#[test] -fn a_metadata_name_on_a_public_address_is_refused_by_its_name_alone() { - struct Stub(IpAddr); - impl Resolver for Stub { - fn resolve(&self, _host: &str) -> Result, String> { - Ok(vec![self.0]) - } - } - // Equinix Metal's metadata service answers on a public address, which is exactly why it is on - // the denylist by NAME: no address predicate can catch it. - let public = IpAddr::V4(Ipv4Addr::new(147, 75, 1, 1)); - let got = resolve_and_pin( - "metadata.platformequinix.com", - 443, - true, - &Stub(public), - GuardPolicy::default(), - ); - assert!( - got.is_err(), - "metadata.platformequinix.com resolved to a PUBLIC address was PINNED, not refused: {got:?}" - ); -} diff --git a/crates/busbar-kernel/src/tests/observability_tests.rs b/crates/busbar-kernel/src/tests/observability_tests.rs index 4a1a521b63..896d6fe527 100644 --- a/crates/busbar-kernel/src/tests/observability_tests.rs +++ b/crates/busbar-kernel/src/tests/observability_tests.rs @@ -166,8 +166,9 @@ fn test_validate_webhook_url_rejects_https_internal_hosts() { "https://[::1]/hook", // IPv6 loopback "https://[fe80::1]/hook", // IPv6 link-local "https://[fc00::1]/hook", // IPv6 unique-local - // THE RANGES THIS GUARD'S HAND-WRITTEN V6 ARM MISSED while `net_guard::ipv6_is_internal` - // — the predicate it was supposed to be a sibling of — had them. v6 multicast reaches every + // THE RANGES THIS GUARD'S HAND-WRITTEN V6 ARM MISSED while + // `busbar_contract::net::ipv6_is_internal` — the predicate it was supposed to be a sibling + // of — had them. v6 multicast reaches every // node on the segment; the IPv4-COMPATIBLE spelling matches no v6 range at all. "https://[ff02::1]/hook", // IPv6 multicast (all-nodes) "https://[::169.254.169.254]/hook", // IPv4-COMPATIBLE metadata @@ -502,8 +503,9 @@ fn a_registry_level_filter_gates_the_otlp_layer() { /// POSITIVE CONTROL (call site 5/5: `observability.rs`, the webhook / OTLP guard). /// /// The same three planted targets every other call site is driven with. This guard is the one that -/// used to keep its OWN two-name `METADATA_HOSTS`; it now reads `net_guard::METADATA_HOSTS`, so the -/// four names it had never heard of are refused here too. +/// used to keep its OWN two-name `METADATA_HOSTS`; it now reads +/// `busbar_contract::net::METADATA_HOSTS`, so the four names it had never heard of are refused here +/// too. #[test] fn planted_blocked_targets_are_refused_by_the_telemetry_guard() { for target in [ diff --git a/crates/busbar-kernel/tests/common/mod.rs b/crates/busbar-kernel/tests/common/mod.rs index 1e5508b7b7..b1e610e83a 100644 --- a/crates/busbar-kernel/tests/common/mod.rs +++ b/crates/busbar-kernel/tests/common/mod.rs @@ -119,6 +119,9 @@ pub struct TestUnits { /// a plane does with one is post it; what this harness does is keep it, so a cell can post it /// onto a real ledger and read the books. pub abandoned: Mutex>, + /// Every usage-tap fault the driver handed these steps, `(dialect, reason)`, in order. Each is + /// also counted on the kernel's own counter, as the root's steps count it. + pub tap_faults: Mutex>, } impl Default for TestUnits { @@ -139,6 +142,7 @@ impl Default for TestUnits { groups: Vec::new(), capped: None, abandoned: Mutex::new(Vec::new()), + tap_faults: Mutex::new(Vec::new()), } } } @@ -355,8 +359,17 @@ macro_rules! step { }}; } -/// The plane driver's kernel steps: told nothing beyond the loop's own seats. -impl busbar_kernel::plane_driver::DriverSteps for TestUnits {} +/// The protocol label this harness counts a usage-tap fault under (its plane has no dialect names). +pub const TAP_FAULT_PROTOCOL: &str = "plane-driver-test"; + +/// The plane driver's kernel steps: told nothing beyond the loop's own seats, save a usage-tap +/// fault, which they keep and count on the kernel's counter exactly as the root's steps do. +impl busbar_kernel::plane_driver::DriverSteps for TestUnits { + fn usage_tap_fault(&self, _ctx: &UnitCtx, dialect: u32, reason: &'static str) { + self.tap_faults.lock().unwrap().push((dialect, reason)); + busbar_kernel::handlers::usage_tap_fault(TAP_FAULT_PROTOCOL, reason); + } +} impl Units for TestUnits { fn arrival(&self, token: &Pass, _ctx: &UnitCtx) -> SeatVerdict { diff --git a/crates/busbar-kernel/tests/no_socket.rs b/crates/busbar-kernel/tests/no_socket.rs new file mode 100644 index 0000000000..04a5177069 --- /dev/null +++ b/crates/busbar-kernel/tests/no_socket.rs @@ -0,0 +1,299 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (C) 2026 Busbar Inc and contributors + +//! THE KERNEL OPENS NO SOCKET (status row 130; BUSBAR-1.6.0.md THE DESIGN §1 and §5). The kernel is a +//! byte pump that knows nothing about transport: the chain is kernel -> `busbar-core-connector` -> +//! transport plugins, the connector binds every listener and dials every connection, and the +//! composition root serves what the connector hands up. So no production source of this crate may +//! name an operating-system socket, a name resolver, or an HTTP client or server stack, and its +//! manifest may ask for none. +//! +//! The tree does not meet that yet (Law 9: a ratchet armed at today's number). [`PINNED`] holds +//! every `(file, needle)` this scan measured when it was armed, and the scan holds the pin to the +//! tree BOTH ways: +//! +//! * a measured pair the pin does not hold is RED: a new socket, resolver or HTTP stack in the +//! kernel, never admitted by adding it to the pin in the change that introduced it; +//! * a pinned pair the tree no longer has is RED too: the drain happened, so the pair is struck in +//! the SAME commit (the pin only goes down). +//! +//! Row 130 is done when [`PINNED`] and [`PINNED_MANIFEST`] are empty. +//! +//! SCOPE. Every `.rs` under `src/` except test scaffolding: a file under a `tests/` directory, a +//! `*_tests.rs` or `tests.rs` file, `src/test_support/**`, and `src/egress/fixtures.rs` (compiled +//! only under `cfg(test)` / `test-support`). Comment lines are blanked first, so prose that names +//! a socket is not a finding. The manifest scan reads `[dependencies]` only (dev-dependencies are +//! test scaffolding). + +use std::collections::BTreeSet; +use std::path::{Path, PathBuf}; + +/// What a kernel source may not name: the OS sockets, the resolver, the HTTP client and server +/// stacks, the WebSocket client and the raw socket builder. +const NEEDLES: &[&str] = &[ + "TcpStream", + "TcpListener", + "UdpSocket", + "UnixStream", + "UnixListener", + "lookup_host", + "to_socket_addrs", + "hyper_util::server", + "hyper_util::client", + "serve_connection", + "hyper::client", + "hyper::server", + "tokio_tungstenite", + "reqwest::", + "socket2", + "axum::serve", +]; + +/// What the kernel's `[dependencies]` may not ask for: the crates that are a socket or an HTTP +/// stack by name, and tokio's `net` feature. +const MANIFEST_NEEDLES: &[&str] = &["hyper-util", "tokio-tungstenite", "reqwest", "socket2"]; + +/// The tokio feature that is the socket layer. +const TOKIO_NET: &str = "tokio:net"; + +/// TODAY'S OFFENDERS, measured on predev after row 130 E1 (the hyper serve loop moved to the root). +/// Each line is struck in the commit that drains it. +const PINNED: &[(&str, &str)] = &[ + // E3: the duplex_ws dial (`TcpStream::connect` + the tungstenite client handshake). + ("src/egress/duplex_ws.rs", "TcpStream"), + ("src/egress/duplex_ws.rs", "tokio_tungstenite"), + // E5: the engine pool (the owned hyper-util client, its https connector and resolver). + ("src/egress/engine/client.rs", "hyper::client"), + ("src/egress/engine/https.rs", "TcpStream"), + ("src/egress/engine/https.rs", "hyper_util::client"), + ("src/egress/engine/mod.rs", "TcpStream"), + ("src/egress/engine/mod.rs", "hyper_util::client"), + ("src/egress/engine/observe.rs", "hyper_util::client"), + ("src/egress/engine/pool.rs", "hyper::client"), + ("src/egress/engine/resolve.rs", "hyper_util::client"), + ("src/egress/mod.rs", "reqwest::"), + ("src/secure.rs", "TcpStream"), +]; + +/// TODAY'S MANIFEST OFFENDERS, struck as the code that needs each one leaves. +const PINNED_MANIFEST: &[&str] = &["hyper-util", "tokio-tungstenite", "reqwest", TOKIO_NET]; + +fn crate_root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) +} + +/// Whether `rel` (crate-relative, `/`-separated) is test scaffolding the scan leaves out. +fn is_scaffolding(rel: &str) -> bool { + let name = rel.rsplit('/').next().unwrap_or(rel); + rel.contains("/tests/") + || name.ends_with("_tests.rs") + || name == "tests.rs" + || rel.starts_with("src/test_support/") + || rel == "src/egress/fixtures.rs" +} + +/// The production `.rs` files under `root/src`, crate-relative and sorted. +fn production_sources(root: &Path) -> Vec { + fn walk(root: &Path, dir: &Path, out: &mut Vec) { + let mut entries: Vec = std::fs::read_dir(dir) + .unwrap_or_else(|e| panic!("read {}: {e}", dir.display())) + .map(|e| e.expect("a directory entry").path()) + .collect(); + entries.sort(); + for path in entries { + if path.is_dir() { + walk(root, &path, out); + } else if path.extension().is_some_and(|x| x == "rs") { + let rel = path + .strip_prefix(root) + .expect("under the crate root") + .to_string_lossy() + .replace('\\', "/"); + if !is_scaffolding(&rel) { + out.push(rel); + } + } + } + } + let mut out = Vec::new(); + walk(root, &root.join("src"), &mut out); + out +} + +/// Every `(file, needle)` a production source under `root` names, comment lines blanked. +fn measure_sources(root: &Path) -> BTreeSet<(String, String)> { + let mut found = BTreeSet::new(); + for rel in production_sources(root) { + let text = + std::fs::read_to_string(root.join(&rel)).unwrap_or_else(|e| panic!("read {rel}: {e}")); + for line in text.lines() { + if line.trim_start().starts_with("//") { + continue; + } + for needle in NEEDLES { + if line.contains(needle) { + found.insert((rel.clone(), (*needle).to_owned())); + } + } + } + } + found +} + +/// Every manifest needle `root/Cargo.toml`'s `[dependencies]` asks for (`tokio:net` for tokio's +/// `net` feature). +fn measure_manifest(root: &Path) -> BTreeSet { + let text = std::fs::read_to_string(root.join("Cargo.toml")).expect("read Cargo.toml"); + let mut found = BTreeSet::new(); + let mut in_deps = false; + for line in text.lines() { + let t = line.trim(); + if t.starts_with('[') { + in_deps = t == "[dependencies]"; + continue; + } + if !in_deps || t.starts_with('#') { + continue; + } + let Some((name, spec)) = t.split_once('=') else { + continue; + }; + let name = name.trim(); + if MANIFEST_NEEDLES.contains(&name) { + found.insert(name.to_owned()); + } + if name == "tokio" && spec.contains("\"net\"") { + found.insert(TOKIO_NET.to_owned()); + } + } + found +} + +/// The two-way verdict of `measured` against `pinned`: the new offenders and the drained pins. +fn verdict( + measured: &BTreeSet, + pinned: &BTreeSet, +) -> (Vec, Vec) { + ( + measured.difference(pinned).cloned().collect(), + pinned.difference(measured).cloned().collect(), + ) +} + +fn pinned_sources() -> BTreeSet<(String, String)> { + PINNED + .iter() + .map(|(f, n)| ((*f).to_owned(), (*n).to_owned())) + .collect() +} + +#[test] +fn the_kernel_names_no_socket_beyond_the_pin() { + let root = crate_root(); + let files = production_sources(&root); + assert!( + files.len() > 100 && files.iter().any(|f| f == "src/lib.rs"), + "the scan reaches the kernel's sources ({} files)", + files.len() + ); + let (new, drained) = verdict(&measure_sources(&root), &pinned_sources()); + assert!( + new.is_empty(), + "busbar-kernel names a socket, resolver or HTTP stack the pin does not hold (THE DESIGN \ + §5: every dial and listener is the connector's): {new:?}" + ); + assert!( + drained.is_empty(), + "pinned offenders the tree no longer has: strike them from PINNED in this commit: \ + {drained:?}" + ); +} + +#[test] +fn the_kernel_manifest_asks_for_no_socket_beyond_the_pin() { + let pinned: BTreeSet = PINNED_MANIFEST.iter().map(|s| (*s).to_owned()).collect(); + let (new, drained) = verdict(&measure_manifest(&crate_root()), &pinned); + assert!( + new.is_empty(), + "busbar-kernel's [dependencies] ask for a socket or HTTP stack the pin does not hold: \ + {new:?}" + ); + assert!( + drained.is_empty(), + "pinned manifest offenders the manifest no longer asks for: strike them from \ + PINNED_MANIFEST in this commit: {drained:?}" + ); +} + +/// RED arms: a planted dial, listener and resolver are each seen; a planted test file and a comment +/// are not; a pin the tree no longer has is reported drained; a planted manifest asks are seen. +#[test] +fn a_planted_socket_is_seen_and_a_drained_pin_is_reported() { + let dir = std::env::temp_dir().join(format!( + "busbar-kernel-no-socket-{}-{}", + std::process::id(), + line!() + )); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(dir.join("src/tests")).expect("mkdir"); + std::fs::write( + dir.join("src/lib.rs"), + "pub async fn dial() { let _ = tokio::net::TcpStream::connect(\"127.0.0.1:1\").await; }\n\ + // a comment naming TcpListener is prose\n\ + pub async fn names() { let _ = tokio::net::lookup_host(\"x:0\").await; }\n", + ) + .expect("write lib"); + std::fs::write( + dir.join("src/serve.rs"), + "use hyper_util::server::conn::auto::Builder;\n", + ) + .expect("write serve"); + std::fs::write( + dir.join("src/tests/far_end_tests.rs"), + "fn f() { let _ = std::net::TcpListener::bind(\"127.0.0.1:0\"); }\n", + ) + .expect("write test file"); + std::fs::write( + dir.join("Cargo.toml"), + "[dependencies]\ntokio = { workspace = true, features = [\"rt\", \"net\"] }\n\ + hyper-util = { workspace = true }\n[dev-dependencies]\nreqwest = { workspace = true }\n", + ) + .expect("write manifest"); + + let measured = measure_sources(&dir); + let want: BTreeSet<(String, String)> = [ + ("src/lib.rs", "TcpStream"), + ("src/lib.rs", "lookup_host"), + ("src/serve.rs", "hyper_util::server"), + ] + .iter() + .map(|(f, n)| ((*f).to_owned(), (*n).to_owned())) + .collect(); + assert_eq!( + measured, want, + "the dial, the resolver and the server stack are seen; the comment and the test file are not" + ); + + let pinned: BTreeSet<(String, String)> = [("src/gone.rs", "TcpStream")] + .iter() + .map(|(f, n)| ((*f).to_owned(), (*n).to_owned())) + .collect(); + let (new, drained) = verdict(&measured, &pinned); + assert_eq!(new.len(), 3, "every unpinned plant is a new offender"); + assert_eq!( + drained, + vec![("src/gone.rs".to_owned(), "TcpStream".to_owned())], + "a pin the tree no longer has is reported drained" + ); + + let manifest = measure_manifest(&dir); + let want: BTreeSet = ["hyper-util", TOKIO_NET] + .iter() + .map(|s| (*s).to_owned()) + .collect(); + assert_eq!( + manifest, want, + "tokio's net feature and hyper-util are seen; a dev-dependency is not" + ); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/busbar-kernel/tests/plane_driver.rs b/crates/busbar-kernel/tests/plane_driver.rs index 915c80da22..2e17a32a4a 100644 --- a/crates/busbar-kernel/tests/plane_driver.rs +++ b/crates/busbar-kernel/tests/plane_driver.rs @@ -492,6 +492,14 @@ impl Double { *i.fields_buf = OutField { name, value }; o.fields_written = 1; o.arena_written = at as u64; + // A plane whose usage tap could not read the answer (modes `tap-bad-json`, + // `tap-unknown`, the latter a byte outside the vocabulary): the fault rides the answer + // that opens the reply, once. + o.tap_fault = match u.mode.as_slice() { + b"tap-bad-json" => busbar_contract::abi::plane::TAP_FAULT_BAD_JSON, + b"tap-unknown" => 0xC8, + _ => busbar_contract::abi::plane::TAP_FAULT_NONE, + }; } let n = u.pending.len().min(i.reply_cap); std::ptr::copy_nonoverlapping(u.pending.as_ptr(), i.reply_buf, n); diff --git a/crates/busbar-kernel/tests/support/plane_driver_cases.rs b/crates/busbar-kernel/tests/support/plane_driver_cases.rs index 6b237e8fbc..72cfaef901 100644 --- a/crates/busbar-kernel/tests/support/plane_driver_cases.rs +++ b/crates/busbar-kernel/tests/support/plane_driver_cases.rs @@ -879,6 +879,95 @@ async fn a_retry_verdict_before_the_first_byte_fails_over() { // `ledger_and_meter`: "`lane` is the SERVING lane"): when the answer commits, the driver names the // member that answered to the money steps, once. +/// THE USAGE-TAP FAULT A DOOR RETURNS REACHES THE KERNEL'S COUNTER (row 155): a plane whose +/// answer carries a usage-tap fault beside its units has it counted, ONCE per unit, by the steps +/// the driver hands it to, on `busbar_billing_tap_decode_fail_total{protocol,reason}` (the +/// counter, labels and increments of 1.5.5's latch). The byte is a closed enum: a value outside +/// the vocabulary is counted under `reason="unknown"`, never ignored and never read as none. RED: +/// drop the driver's read of `tap_fault` and neither the steps nor the counter see a fault. +#[tokio::test] +async fn a_usage_tap_fault_the_door_returns_reaches_the_kernel_counter() { + use metrics::{ + Counter, CounterFn, Gauge, Histogram, Key, KeyName, Metadata, Recorder, SharedString, Unit, + }; + use std::collections::BTreeMap; + type Counts = Arc>>; + struct Cell(Counts, String); + impl CounterFn for Cell { + fn increment(&self, value: u64) { + *self.0.lock().unwrap().entry(self.1.clone()).or_insert(0) += value; + } + fn absolute(&self, value: u64) { + self.0.lock().unwrap().insert(self.1.clone(), value); + } + } + struct Local(Counts); + impl Recorder for Local { + fn describe_counter(&self, _: KeyName, _: Option, _: SharedString) {} + fn describe_gauge(&self, _: KeyName, _: Option, _: SharedString) {} + fn describe_histogram(&self, _: KeyName, _: Option, _: SharedString) {} + fn register_counter(&self, key: &Key, _: &Metadata<'_>) -> Counter { + let labels: Vec = key + .labels() + .map(|l| format!("{}={}", l.key(), l.value())) + .collect(); + let series = format!("{}{{{}}}", key.name(), labels.join(",")); + Counter::from_arc(Arc::new(Cell(Arc::clone(&self.0), series))) + } + fn register_gauge(&self, _: &Key, _: &Metadata<'_>) -> Gauge { + Gauge::noop() + } + fn register_histogram(&self, _: &Key, _: &Metadata<'_>) -> Histogram { + Histogram::noop() + } + } + let counts = Counts::default(); + let local = Local(Arc::clone(&counts)); + let _recording = metrics::set_default_local_recorder(&local); + let mut ran = 0u64; + for way in ways() { + ran += 1; + for (member, reason) in [("tap-bad-json", "bad_json"), ("tap-unknown", "unknown")] { + let r = rig(way, BufferCaps::default(), Book::default()); + let (steps, far, caller) = ( + TestUnits::passing(), + Far::new(&[member], CHUNKS), + Caller::default(), + ); + let units = r + .driver + .unit(&steps, &far, &caller, arrival("/call", b"p"), 0); + assert!(matches!(drive(&units).await, Outcome::Completed)); + let seen: Vec<&str> = steps + .tap_faults + .lock() + .unwrap() + .iter() + .map(|f| f.1) + .collect(); + assert_eq!(seen, vec![reason], "{way:?}: one fault per unit"); + } + let ok = TestUnits::passing(); + let (far, caller) = (Far::new(&["ok"], CHUNKS), Caller::default()); + let r = rig(way, BufferCaps::default(), Book::default()); + let units = r.driver.unit(&ok, &far, &caller, arrival("/call", b"p"), 0); + assert!(matches!(drive(&units).await, Outcome::Completed)); + assert!( + ok.tap_faults.lock().unwrap().is_empty(), + "{way:?}: an answer carrying none counts none" + ); + } + let series = |reason: &str| { + format!( + "busbar_billing_tap_decode_fail_total{{protocol={},reason={reason}}}", + super::common::TAP_FAULT_PROTOCOL + ) + }; + let counts = counts.lock().unwrap().clone(); + assert_eq!(counts.get(&series("bad_json")), Some(&ran), "{counts:?}"); + assert_eq!(counts.get(&series("unknown")), Some(&ran), "{counts:?}"); +} + /// RED: without the driver's call the money steps never learn the serving member. #[tokio::test] async fn the_answering_member_is_named_to_the_money_steps_once() { diff --git a/crates/busbar-llm/src/engine/response_body.rs b/crates/busbar-llm/src/engine/response_body.rs index 9253be1a09..e6f65773f1 100644 --- a/crates/busbar-llm/src/engine/response_body.rs +++ b/crates/busbar-llm/src/engine/response_body.rs @@ -410,6 +410,14 @@ where if let Some(reason) = end.stream_fault { this.record_transient(reason); } + // THE USAGE-TAP FAULT the relay's tap returned as data (row 155): the kernel + // counts it, under the caller's dialect the tap read the body as. + if let Some(fault) = end.tap_fault { + busbar_kernel::handlers::usage_tap_fault( + this.relay.ingress(), + fault.reason(), + ); + } // The stream's drops (design F3 "Drops"), already warned once each: one audit row // per wire path, the twin of the buffered answer's rows. if let (Some(host), Some(rt)) = (this.host.as_ref(), this.rt.as_ref()) { diff --git a/crates/busbar-llm/src/engine/tests/forward_pool_integration_tests.rs b/crates/busbar-llm/src/engine/tests/forward_pool_integration_tests.rs index e0a8263840..c0543a05a3 100644 --- a/crates/busbar-llm/src/engine/tests/forward_pool_integration_tests.rs +++ b/crates/busbar-llm/src/engine/tests/forward_pool_integration_tests.rs @@ -49,17 +49,16 @@ use std::sync::Arc; /// Pre-release latency capture (opt-in). Drives N in-process requests through the full forward path /// against an instant mock upstream and reports the request-handling-time distribution — a /// reproducible latency regression metric that runs in the normal test build (no cert, no key, no -/// external mock). It's a no-op unless `BUSBAR_CAPTURE_METRICS` is set, so it costs nothing in CI. +/// external mock). A measurement, not a gate: `#[ignore]`d, so the default run neither spends it +/// nor counts it as a pass (status row 114; it was an env-gated no-op that passed in 0s). /// /// For release-representative numbers, build under `--release`: -/// BUSBAR_CAPTURE_METRICS=1 cargo test --release -p busbar capture_latency_metrics -- --nocapture +/// cargo test --release -p busbar capture_latency_metrics -- --ignored --nocapture /// Emits a line the site metrics harness parses: `BUSBAR_METRICS busbar.latency.inproc_handle_us ...` #[tokio::test] +#[ignore = "a measurement: run with --ignored --nocapture (BUSBAR_CAPTURE_N sets the count)"] async fn capture_latency_metrics() { crate::testkit::install_test_seams(); - if std::env::var("BUSBAR_CAPTURE_METRICS").is_err() { - return; // opt-in only — normal test runs skip this. - } let n: usize = std::env::var("BUSBAR_CAPTURE_N") .ok() .and_then(|s| s.parse().ok()) diff --git a/crates/busbar-plane-decisions/src/codec/mod.rs b/crates/busbar-plane-decisions/src/codec/mod.rs index 29ac3a65c7..f8122c99c1 100644 --- a/crates/busbar-plane-decisions/src/codec/mod.rs +++ b/crates/busbar-plane-decisions/src/codec/mod.rs @@ -41,19 +41,41 @@ pub const PTR_ID: &str = "/id"; /// without this plane growing a second top-level member to ignore. pub const PTR_USAGE_UNITS: &str = "/usage/units"; -/// Every pointer this plane resolves in a RESPONSE body (`systemone` or `models`). Declared here +/// The pointers a SUCCESSFUL `systemone` response reports its input and output tokens at, where it +/// reports them (DECISIONS Q1-5: "usage pointers are DIALECT data (/usage/units, +/// /usage/input_tokens,/usage/output_tokens)"). +pub const PTR_USAGE_INPUT_TOKENS: &str = "/usage/input_tokens"; +/// See [`PTR_USAGE_INPUT_TOKENS`]. +pub const PTR_USAGE_OUTPUT_TOKENS: &str = "/usage/output_tokens"; + +/// The pointer a SUCCESSFUL `nanojev` answer states the upstream's own billing unit at: the states +/// its evaluation ran (THE DESIGN section 2: "it meters the upstream's own billing unit, counted +/// from the answer already read (`execution.states`; an answer with no count refuses loudly"). +pub const PTR_EXECUTION_STATES: &str = "/execution/states"; + +/// Every pointer this plane resolves in a RESPONSE body (`systemone`, `evaluate` or `models`). Declared here /// once, so the "never resolves `/state` or `/answers`" claim is checkable by reading one list /// rather than auditing every call site. -pub const RESPONSE_PTRS: &[&str] = &[PTR_ERROR, PTR_REQUEST_ID, PTR_ID, PTR_USAGE_UNITS]; - -/// Every pointer this plane resolves in a REQUEST body. -/// -/// Empty. A `systemone` request carries the caller's `state` and nothing else this plane has a use -/// for — there is no safe member to declare a pointer at, so none is declared. This is not an -/// oversight the way an empty ingress pointer list would be for MCP/A2A (which read a method name -/// and an id out of every request): jev names its operation in the path, not the body, so the -/// REQUEST body genuinely carries nothing this plane reads. -pub const REQUEST_PTRS: &[&str] = &[]; +pub const RESPONSE_PTRS: &[&str] = &[ + PTR_ERROR, + PTR_REQUEST_ID, + PTR_ID, + PTR_USAGE_UNITS, + PTR_USAGE_INPUT_TOKENS, + PTR_USAGE_OUTPUT_TOKENS, + PTR_EXECUTION_STATES, +]; + +/// The pointer a request names the model it is routed over at: its top-level `model` (THE DESIGN +/// section 2, model resolution; DECISIONS D8b "REQUEST_PTRS += /model (metadata)"). Routing +/// metadata, never decision content. +pub const PTR_MODEL: &str = "/model"; + +/// Every pointer this plane resolves in a REQUEST body: the model it routes over, and nothing else. +/// A `systemone` request carries the caller's `state`, which this plane never reads; jev names its +/// operation in the path, so the top-level `model` is the one member routing needs +/// (`driven::route_model`) and the one `upstream_model` rewrites (`driven::spliced`). +pub const REQUEST_PTRS: &[&str] = &[PTR_MODEL]; /// The span view of a body, built from one of the two declared pointer lists above. /// diff --git a/crates/busbar-plane-decisions/src/config.rs b/crates/busbar-plane-decisions/src/config.rs index 4c736e96ba..8f5e5d3856 100644 --- a/crates/busbar-plane-decisions/src/config.rs +++ b/crates/busbar-plane-decisions/src/config.rs @@ -89,6 +89,15 @@ pub struct DecisionsSection { /// transport-protocol string). pub const PROTOCOL: &str = "jev"; +/// THE SECOND WIRE DIALECT (THE DESIGN section 2, the decisions bullet): "a second dialect exists +/// only where a provider's request spine really differs (NanoJev is the one, dialect `nanojev`: +/// `/api/evaluate` is its second exact claim; a model's dialect is its override or its provider's +/// protocol ...)". +pub const PROTOCOL_NANOJEV: &str = "nanojev"; + +/// Every dialect this plane speaks, in its dialect-index order. +pub const PROTOCOLS: &[&str] = &[PROTOCOL, PROTOCOL_NANOJEV]; + /// THE DECLARING SECTION: the top-level config key whose presence declares this plane, beside /// `pools:`, `tools:`, `agents:` and `streams:`. The plane owns it and says so here; the composition /// root reads this constant for its registry declaration and spells no literal of its own. @@ -147,14 +156,26 @@ pub fn validate_cross_refs( } )); } - Some(protocol) if protocol != PROTOCOL => { + Some(protocol) + if !PROTOCOLS + .contains(&model_cfg.protocol.as_deref().unwrap_or(protocol.as_str())) => + { + let (key, protocol) = match model_cfg.protocol.as_deref() { + Some(own) => (format!("decisions.models.{model_name}.protocol"), own), + None => ( + format!( + "decisions.models.{model_name}.provider '{}'", + model_cfg.provider + ), + protocol.as_str(), + ), + }; errors.push(format!( - "decisions.models.{model_name}.provider '{}' resolves to protocol '{protocol}', \ - but the decision plane speaks only '{PROTOCOL}' (jev is a direct HTTP+JSON \ - passthrough with no translating IR — BUSBAR-1.6.0.md #51: an unknown dialect \ - FAILS CLOSED rather than boot). Point decisions.models.{model_name} at a \ - provider whose protocol resolves to '{PROTOCOL}', or remove this model.", - model_cfg.provider + "{key} resolves to protocol '{protocol}', but the decision plane speaks only \ + {} (BUSBAR-1.6.0.md #51: an unknown dialect FAILS CLOSED rather than boot). \ + Point decisions.models.{model_name} at a provider whose protocol resolves to \ + one of them, or remove this model.", + PROTOCOLS.join(", ") )); } Some(_) => {} diff --git a/crates/busbar-plane-decisions/src/driven.rs b/crates/busbar-plane-decisions/src/driven.rs index 54ef26f741..244aa1f9de 100644 --- a/crates/busbar-plane-decisions/src/driven.rs +++ b/crates/busbar-plane-decisions/src/driven.rs @@ -13,39 +13,54 @@ //! | `refusal` | [`refusal_body`]: the kernel's status and text, in this dialect's error shape | //! //! Every answer is plain data. Nothing here holds a byte across calls: [`FarEndReading`] is a value -//! the caller owns for the length of one unit, and it is the only accumulating thing in the module. +//! the caller owns for the length of one unit, in fixed space, and it is the only accumulating +//! thing in the module. //! Writing these answers into the host buffers of the plane ABI (`busbar_contract::abi::plane`) is //! the plane door's job, one generic adapter for every plane; it is not this plane's. //! -//! ZERO OR SEVERAL DECISIONS MODELS — the driver's rule is today's behaviour, byte for byte. A jev -//! request names no model, so it has a destination only when `decisions.models` holds exactly one. -//! Measured on the busbar binary before the driver serves this plane: +//! MODEL RESOLUTION (THE DESIGN section 2, the decisions bullet; DECISIONS D8b): "a request routes +//! by its model; one configured model and none named is the default (byte-identical); more than one +//! and none named is 400; an unknown model is 404. `upstream_model` rewrites only the top-level model +//! value, by span splice, when it is set and differs; otherwise the body passes through." //! -//! | config | `--validate` and boot | `POST /v1/systemone` | -//! |---|---|---| -//! | no jev provider, `decisions.models: {}` | accepted | `404`, the unclaimed-route body | -//! | a jev provider, `decisions.models: {}` | refused, `BUSBAR-3015`: provider '' has unknown protocol 'jev' | (no boot) | -//! | one model | accepted | `404`, the unclaimed-route body (the served operation is new surface) | -//! | two or more models | accepted | `404`, the unclaimed-route body | +//! | config | `POST /v1/systemone` | +//! |---|---| +//! | `decisions.models: {}` | `404`, the kernel's unclaimed-route body (nothing is claimed) | +//! | one model, none named | routed over that model, the body byte for byte | +//! | several models, none named | `400` ([`Unrouted::Unnamed`]) | +//! | a model named | routed over it; a name no model has is `404` ([`Unrouted::Unknown`]) | +//! +//! With no model configured the snapshot publishes no claim ([`served_claims`]), so the request +//! falls through to the kernel's unclaimed-route body, +//! `{"error":{"code":null,"message":"the requested resource was not found","param":null,"type":"not_found_error"}}`, +//! byte for byte as before. The top-level `model` ([`codec::PTR_MODEL`]) is the one request member +//! this plane reads, and only to route ([`route_model`]) and splice ([`spliced`]). //! -//! The unclaimed-route body is `{"error":{"code":null,"message":"the requested resource was not -//! found","param":null,"type":"not_found_error"}}`. So with zero or several models the driver serves -//! no `systemone` claim for this generation (the snapshot publishes [`served_claims`], empty) and the request falls -//! through to that same 404; there is no boot check and no new refusal. Only exactly one model -//! mounts the claim. +//! THE SECOND DIALECT (THE DESIGN section 2): "NanoJev is the one, dialect `nanojev`: `/api/evaluate` +//! is its second exact claim; a model's dialect is its override or its provider's protocol; a model +//! of the wrong dialect gets that dialect's existing unknown-model refusal; it meters the upstream's +//! own billing unit, counted from the answer already read (`execution.states`; an answer with no +//! count refuses loudly ...), with no extra egress". Each dialect's claim is published when the +//! generation configures a model of that dialect ([`served_claims`]); a request routes among the +//! models of the dialect its claim is ([`route_model`]), so a model of the other dialect is that +//! dialect's unknown model (`404`). Its answer is read by its dialect's count +//! ([`FarEndReading::for_dialect`]): `/execution/states`, required. //! //! WHAT THIS PLANE DOES NOT CLAIM: `GET /v1/models`. That path keeps its 1.5.5 bytes (the model //! list busbar already serves there), so [`tail::CLAIMS`] and [`tail::OP_CLASSES`] carry `systemone` alone and //! [`arrive`] answers the models operation as unclaimed. //! -//! Money-blind, like the rest of the crate: the plane reports how many decision units the far end -//! said it used, in its one billable class, and never what they are worth. +//! Money-blind, like the rest of the crate: the plane reports what a successful answer bills, in its +//! billable classes, and never what it is worth (THE DESIGN section 7, the owner money rulings +//! 2026-09-28: "bills one `decision` per successful, settled answer from every provider; its card +//! prices `decision`, `input_tokens` and `output_tokens` (family `decision`, all three required, 0 +//! = free); the hosted provider keeps 1.5.5's `/usage/units` as the decision count"). use busbar_contract::ids::{MeterClassId, OpClassId}; use crate::codec::{ - self, error_body, CONTENT_TYPE_JSON, EGRESS_SCHEME, FIELD_CONTENT_TYPE, PTR_ERROR, - PTR_USAGE_UNITS, + self, error_body, CONTENT_TYPE_JSON, EGRESS_SCHEME, FIELD_CONTENT_TYPE, PTR_EXECUTION_STATES, + PTR_USAGE_INPUT_TOKENS, PTR_USAGE_OUTPUT_TOKENS, PTR_USAGE_UNITS, }; use crate::ops; @@ -60,8 +75,15 @@ pub mod tail { /// The sections this plane reads and does not own: every model names its provider there. pub const SECTIONS_CONSUMED: &[&str] = &["providers"]; - /// The one dialect this plane speaks. Dialect index 0 everywhere below. - pub const DIALECTS: &[&str] = &[config::PROTOCOL]; + /// The dialects this plane speaks, in index order: `jev` ([`DIALECT_JEV`]) and `nanojev` + /// ([`DIALECT_NANOJEV`]). + pub const DIALECTS: &[&str] = config::PROTOCOLS; + + /// The index of `jev` in [`DIALECTS`]. + pub const DIALECT_JEV: u32 = 0; + + /// The index of `nanojev` in [`DIALECTS`]. + pub const DIALECT_NANOJEV: u32 = 1; /// The outbound auth style a jev far end is reached under: the operator's credential as a /// bearer (the signed design: "auth = core gateway credential, plane returns a @@ -70,7 +92,10 @@ pub mod tail { /// Each dialect's default outbound auth style, `(dialect index, style)` (the design, "Outbound auth", step 2: /// a provider entry's `auth:`, else this; ARCHITECT Q-L1-AUTH (A), 2026-10-03). - pub const DIALECT_AUTH: &[(u32, &str)] = &[(0, OUTBOUND_STYLE)]; + pub const DIALECT_AUTH: &[(u32, &str)] = &[ + (DIALECT_JEV, OUTBOUND_STYLE), + (DIALECT_NANOJEV, OUTBOUND_STYLE), + ]; /// The resource granularity a grant names: a configured decision provider. pub const SCOPE_KINDS: &[&str] = &["decision_provider"]; @@ -84,19 +109,40 @@ pub mod tail { /// The record resource kind a registration is audited under: the scope kind. pub const AUDIT_KIND: &str = SCOPE_KINDS[0]; - /// The op classes this plane serves, in index order. `systemone` alone (see the module doc). - pub const OP_CLASSES: &[OpClassId] = &[ops::OP_SYSTEMONE]; + /// The op classes this plane serves, in index order: each dialect's decision call (see the + /// module doc). + pub const OP_CLASSES: &[OpClassId] = &[ops::OP_SYSTEMONE, ops::OP_EVALUATE]; - /// The billable classes, in index order, each with its family: the far end's decision count, - /// and the fee unit (also a class: the tail check holds `fee_units ⊆ billable_classes`). + /// The billable classes, in index order, each with its family: the decision count, the fee + /// unit (also a class: the tail check holds `fee_units ⊆ billable_classes`), and the far end's + /// input and output tokens. THE DESIGN section 7 (owner money rulings 2026-09-28): the card + /// "prices `decision`, `input_tokens` and `output_tokens` (family `decision`, all three + /// required, 0 = free)". pub const BILLABLE_CLASSES: &[(MeterClassId, &str)] = &[ - (meta::CLASS_DECISION, "decision"), + (meta::CLASS_DECISION, DECISION_FAMILY), (MeterClassId::new(FEE_PER_REQUEST), FEE_FAMILY), + (CLASS_INPUT_TOKENS, DECISION_FAMILY), + (CLASS_OUTPUT_TOKENS, DECISION_FAMILY), ]; + /// The family every priced class of this plane is in. + pub const DECISION_FAMILY: &str = "decision"; + + /// The far end's input tokens, where its answer reports them (`/usage/input_tokens`). + pub const CLASS_INPUT_TOKENS: MeterClassId = MeterClassId::new("input_tokens"); + + /// The far end's output tokens, where its answer reports them (`/usage/output_tokens`). + pub const CLASS_OUTPUT_TOKENS: MeterClassId = MeterClassId::new("output_tokens"); + /// The index of [`meta::CLASS_DECISION`] in [`BILLABLE_CLASSES`]. pub const CLASS_DECISION_INDEX: u32 = 0; + /// The index of [`CLASS_INPUT_TOKENS`] in [`BILLABLE_CLASSES`]. + pub const CLASS_INPUT_TOKENS_INDEX: u32 = 2; + + /// The index of [`CLASS_OUTPUT_TOKENS`] in [`BILLABLE_CLASSES`]. + pub const CLASS_OUTPUT_TOKENS_INDEX: u32 = 3; + /// THE FEE UNIT: the request fee (`decisions.fees.per_request`), counted `1` on a unit whose /// far end answered a success, so a refused or failed unit owes none (the signed design C-3, /// "billable-success fee gated kernel-side", the tool plane's twin; ARCHITECT Q-L5-FEE (C)). A @@ -117,21 +163,143 @@ pub mod tail { /// The plane names the style and never holds what is behind it. pub const NEEDS: &[(&str, &str)] = &[(claims::TRANSPORT, OUTBOUND_STYLE)]; - /// The claims the generation snapshot publishes, `(verb, target)`. - pub const CLAIMS: &[(&str, &str)] = &[("POST", ops::PATH_SYSTEMONE)]; + /// The claims a generation snapshot may publish, `(verb, target, dialect)`: each dialect's one + /// exact claim. + pub const CLAIMS: &[(&str, &str, u32)] = &[ + ("POST", ops::PATH_SYSTEMONE, DIALECT_JEV), + ("POST", ops::PATH_EVALUATE, DIALECT_NANOJEV), + ]; } -/// The claims a generation's snapshot publishes, given how many `decisions.models` it configures: -/// [`tail::CLAIMS`] for exactly one, none otherwise (see the module doc's measured table). +/// The claims a generation's snapshot publishes, given the dialect of each model it configures: +/// each dialect's claim when at least one model is of it, none for none (see the module doc). #[must_use] -pub fn served_claims(models: usize) -> &'static [(&'static str, &'static str)] { - if models == 1 { - tail::CLAIMS - } else { - &[] +pub fn served_claims(dialects: &[u32]) -> Vec<(&'static str, &'static str, u32)> { + tail::CLAIMS + .iter() + .filter(|(_, _, dialect)| dialects.contains(dialect)) + .copied() + .collect() +} + +/// A protocol's index in [`tail::DIALECTS`], where this plane speaks it. +#[must_use] +pub fn dialect_of(protocol: &str) -> Option { + tail::DIALECTS + .iter() + .position(|d| *d == protocol) + .and_then(|i| u32::try_from(i).ok()) +} + +/// Why a claimed request routes over no configured model (THE DESIGN section 2: "more than one and +/// none named is 400; an unknown model is 404"). +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Unrouted { + /// The request names no model and the generation configures several. + Unnamed, + /// The request's `model` is not a string. + NotAName, + /// The request names a model the generation does not configure. + Unknown(String), +} + +impl Unrouted { + /// The status the refusal wears. + #[must_use] + pub const fn status(&self) -> u16 { + match self { + Self::Unnamed | Self::NotAName => 400, + Self::Unknown(_) => 404, + } + } + + /// The words the caller reads, in the dialect's error shape ([`refusal_body`]). + #[must_use] + pub fn text(&self) -> String { + match self { + Self::Unnamed => "the request names no model, and several decisions models are \ + configured: name one in `model`" + .to_string(), + Self::NotAName => "`model` must be a string naming a decisions model".to_string(), + Self::Unknown(model) => { + format!("The model '{model}' does not exist or you do not have access to it.") + } + } + } +} + +/// The top-level `model` a request body names: `Ok(None)` when it names none, and +/// [`Unrouted::NotAName`] when the member is there but is not a string. +/// +/// # Errors +/// +/// [`Unrouted::NotAName`], as above. +pub fn requested_model(body: &[u8]) -> Result>, Unrouted> { + let Some(raw) = codec::read_raw(body, codec::PTR_MODEL) else { + return Ok(None); + }; + if let Ok(name) = serde_json::from_slice::<&str>(raw) { + return Ok(Some(std::borrow::Cow::Borrowed(name))); + } + serde_json::from_slice::(raw) + .map(|name| Some(std::borrow::Cow::Owned(name))) + .map_err(|_| Unrouted::NotAName) +} + +/// THE MODEL A CLAIMED REQUEST ROUTES OVER among the `models` its claim's dialect `serves` (the +/// generation's configured names, each with what this plane keeps of it): the one it names, else +/// the only one of that dialect configured. Answers the configured name and its entry. A model of +/// another dialect is this dialect's unknown model (THE DESIGN section 2: "a model of the wrong +/// dialect gets that dialect's existing unknown-model refusal"). +/// +/// # Errors +/// +/// [`Unrouted`]: several configured and none named, a `model` that is not a string, or a name none +/// of the dialect's models has. +pub fn route_model<'m, V>( + body: &[u8], + models: &'m std::collections::BTreeMap, + serves: impl Fn(&V) -> bool, +) -> Result<(&'m str, &'m V), Unrouted> { + match requested_model(body)? { + Some(name) => models + .get_key_value(name.as_ref()) + .filter(|(_, v)| serves(v)) + .map(|(k, v)| (k.as_str(), v)) + .ok_or_else(|| Unrouted::Unknown(name.into_owned())), + None => { + let mut all = models.iter().filter(|(_, v)| serves(v)); + match (all.next(), all.next()) { + (Some((k, v)), None) => Ok((k.as_str(), v)), + _ => Err(Unrouted::Unnamed), + } + } } } +/// THE `upstream_model` SPLICE: `body` with its top-level `model` value replaced by `upstream`, when +/// the body names a model and it differs from `upstream`; `None` when the body passes through +/// unchanged. Every byte outside the value's span is kept: member order, whitespace and the rest of +/// the document are the caller's. +#[must_use] +pub fn spliced(body: &[u8], upstream: &str) -> Option> { + let busbar_contract::spans::Resolved::Found(span) = + busbar_contract::spans::resolve_pointer(body, codec::PTR_MODEL) + else { + return None; + }; + let named = requested_model(body).ok().flatten()?; + if named == upstream { + return None; + } + let value = serde_json::to_string(upstream).ok()?; + let mut out = Vec::with_capacity(body.len() - (span.end - span.start) + value.len()); + out.extend_from_slice(body.get(..span.start)?); + out.extend_from_slice(value.as_bytes()); + out.extend_from_slice(body.get(span.end..)?); + Some(out) +} + /// Whether the kernel must verify a principal before the first piece. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum PrincipalNeed { @@ -156,19 +324,23 @@ pub struct Arrived { pub dialect: u32, } -/// Classify an arriving request by its verb and target. `None` for anything this plane does not -/// claim, including `GET /v1/models`. +/// Classify an arriving request by its verb and target: its operation, and the dialect its claim +/// is. `None` for anything this plane does not claim, including `GET /v1/models`. #[must_use] pub fn arrive(verb: &str, target: &str) -> Option { let row = ops::row_for(verb, target)?; let op_class = tail::OP_CLASSES.iter().position(|op| *op == row.op)?; + let dialect = tail::CLAIMS + .iter() + .find(|(v, t, _)| *t == target && v.eq_ignore_ascii_case(verb))? + .2; Some(Arrived { op: row.op, op_class: u32::try_from(op_class).ok()?, // A decision is billed to someone: the provider account is the deployment's, and the unit // is the caller's. principal: PrincipalNeed::Required, - dialect: 0, + dialect, }) } @@ -207,15 +379,20 @@ pub struct FarEndRequest<'a> { pub body: &'a [u8], } -/// Answer an ATTEMPT piece: the same request on every attempt, whichever member the kernel picked. -/// jev names no provider on the wire, so the member changes nothing in the request. Named -/// `attempt_request`, not `attempt`, so it is not a cross-plane re-spelling of llm/a2a's `attempt` -/// (structure-lint plane-dup; ARCHITECT ruling 2b 2026-10-04). +/// Answer an ATTEMPT piece of a unit of `dialect`: the same request on every attempt, whichever +/// member the kernel picked, at the dialect's own path. Neither dialect names a provider on the +/// wire, so the member changes nothing in the request. Named `attempt_request`, not `attempt`, so +/// it is not a cross-plane re-spelling of llm/a2a's `attempt` (structure-lint plane-dup; ARCHITECT +/// ruling 2b 2026-10-04). #[must_use] -pub fn attempt_request(caller_body: &[u8]) -> FarEndRequest<'_> { +pub fn attempt_request(dialect: u32, caller_body: &[u8]) -> FarEndRequest<'_> { + let target = tail::CLAIMS + .iter() + .find(|(_, _, d)| *d == dialect) + .map_or(ops::PATH_SYSTEMONE, |(_, t, _)| t); FarEndRequest { verb: "POST", - target: ops::PATH_SYSTEMONE, + target, fields: [(FIELD_CONTENT_TYPE, CONTENT_TYPE_JSON)], auth: EGRESS_SCHEME, body: caller_body, @@ -233,64 +410,343 @@ pub struct Units { pub amount: u64, } +/// A usage member the far end's success answer carries but that is not a whole count: the money +/// rule's loud failure (finding 395, kept: "a usage member present but not a whole count is +/// LOCATED, never dropped"), never a silent 0. Names the member's pointer. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Unreadable(pub &'static str); + +/// What one answer bills, in [`tail::BILLABLE_CLASSES`]' order of reading: the decision count, +/// the input and output tokens, and the request fee. `None` = nothing in that class. +pub type Counts = [Option; 4]; + +/// A usage member as the answer states it. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +enum Member { + /// The answer has no such member. + #[default] + Absent, + /// A whole count. + Whole(u64), + /// Present, and not a whole count. + Unreadable, +} + +/// What a member of the answer is to the reading: `/error`, `/usage`, one of the three usage +/// counts (by its index in its dialect's [`Usage::counted`]), or none of them. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +enum Slot { + #[default] + Other, + Error, + Usage, + Count(usize), +} + +/// Where a dialect's answer states its counts: the top-level member holding them, and the members +/// under it, `(name, pointer)`, in [`Counts`]' order (decision, input tokens, output tokens). +/// Usage pointers are dialect data (THE DESIGN section 2). +struct Usage { + parent: &'static [u8], + counted: &'static [(&'static [u8], &'static str)], + /// The decision count is required: an answer with none refuses loudly. + required: bool, +} + +/// Each dialect's [`Usage`], in [`tail::DIALECTS`]' order: `jev` reads `/usage/units` (one decision +/// when absent) and its tokens; `nanojev` reads the upstream's own billing unit, +/// `/execution/states`, and an answer with no count refuses loudly. +const USAGE: [Usage; 2] = [ + Usage { + parent: b"usage", + counted: &[ + (b"units", PTR_USAGE_UNITS), + (b"input_tokens", PTR_USAGE_INPUT_TOKENS), + (b"output_tokens", PTR_USAGE_OUTPUT_TOKENS), + ], + required: false, + }, + Usage { + parent: b"execution", + counted: &[(b"states", PTR_EXECUTION_STATES)], + required: true, + }, +]; + +/// The longest member name compared ("output_tokens"), with room. +const KEY_CAP: usize = 16; +/// The longest scalar a whole count is spelled in (`u64::MAX` is 20 digits), with room. +const SCALAR_CAP: usize = 24; + /// The far end's answer, read as it is relayed. /// -/// Every piece is relayed to the caller unchanged, as it arrives. The count lives inside the body -/// (`/usage/units`), so the bytes are also kept until the last piece, and the count is read once, -/// over the whole answer. +/// Every piece is relayed to the caller unchanged, as it arrives, and read as it passes: a scan of +/// the JSON structure that keeps only what the money rule reads (whether `/error` is there and not +/// `null`, and the dialect's counts: `/usage/*`, or `/execution/states`), in fixed space. No byte of the answer is kept, so an +/// answer of any size is read in the same few bytes (THE DESIGN section 11.2: "no allocation and no +/// blocking on the hot path"), and the pieces may split the answer anywhere. #[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct FarEndReading { - body: Vec, + /// The nesting depth: `1` inside the top-level value. + depth: u32, + /// Whether the container at depth 1 and at depth 2 is an object. + object: [bool; 3], + /// In an object at depth 1 or 2: the next string is a member name. + want_key: bool, + /// Inside a string; the string is a member name; the last byte was its escape. + in_str: bool, + str_key: bool, + escaped: bool, + /// The member name being read (cut at [`KEY_CAP`]: a longer one is none compared). + key: [u8; KEY_CAP], + key_len: usize, + /// The member a value at depth 1, and at depth 2, belongs to. + at: [Slot; 3], + /// The scalar being read for `scalar_of` (cut at [`SCALAR_CAP`]: a longer one is no count). + scalar: [u8; SCALAR_CAP], + scalar_len: usize, + scalar_of: Option, + /// `/error`: absent (`None`), `null` (`Some(false)`), or there (`Some(true)`). + error: Option, + /// The counts, in the dialect's [`Usage::counted`] order. + members: [Member; 3], + /// The dialect the answer is in ([`USAGE`]'s index). + dialect: u32, } impl FarEndReading { - /// A reading with nothing received. + /// A `jev` reading with nothing received. #[must_use] pub fn new() -> Self { Self::default() } + /// A reading of an answer in `dialect` ([`tail::DIALECTS`]' index), nothing received. + #[must_use] + pub fn for_dialect(dialect: u32) -> Self { + Self { + dialect, + ..Self::default() + } + } + + /// The dialect's [`Usage`]. + fn usage(&self) -> &'static Usage { + USAGE.get(self.dialect as usize).unwrap_or(&USAGE[0]) + } + /// Take one far-end piece and return the bytes to relay to the caller: the piece itself. pub fn piece<'p>(&mut self, bytes: &'p [u8]) -> &'p [u8] { - self.body.extend_from_slice(bytes); + for &b in bytes { + self.byte(b); + } bytes } - /// The units the far end reported, read at the last piece, by the rule the plane's - /// `decode_response` reads them (`plane.rs`): a count is reported only for an answer with no - /// `/error` member, whose `/usage/units` is a whole number a signed 64-bit count holds. An - /// error answer, or a count that is missing, negative, fractional or past `i64::MAX`, reports - /// nothing. The status plays no part: `decode_response` judges success by the `/error` member - /// alone. - #[must_use] - pub fn units(&self) -> Option { - if codec::has(&self.body, PTR_ERROR) { - return None; + /// The member at the current depth a value starting now belongs to. + fn slot(&self) -> Slot { + match self.depth { + 1 if self.object[1] => self.at[1], + 2 if self.object[2] && self.at[1] == Slot::Usage => self.at[2], + _ => Slot::Other, } - codec::read_u64(&self.body, PTR_USAGE_UNITS) - .filter(|amount| i64::try_from(*amount).is_ok()) - .map(|amount| Units { - class: tail::CLASS_DECISION_INDEX, - reported: true, - amount, - }) } - /// THE REQUEST FEE, read at the last piece: `1` of [`tail::FEE_PER_REQUEST`] for an answer the - /// far end gave as a success — a `2xx` status and no `/error` member, the success rule - /// [`Self::units`] reads — and nothing otherwise, so a refused or failed unit owes no fee - /// (billable success only). + /// Whether the container the reading is in is an object it reads member names of. + fn in_object(&self) -> bool { + matches!(self.depth, 1 | 2) && self.object[self.depth as usize] + } + + /// A value starts: `scalar` = it is a bare scalar (a number, `true`, `false`, `null`). + fn value(&mut self, scalar: Option) { + let slot = self.slot(); + match (slot, scalar) { + (Slot::Error, None) => self.error = Some(true), + (Slot::Count(i), None) => self.members[i] = Member::Unreadable, + (Slot::Error | Slot::Count(_), Some(b)) => { + self.scalar[0] = b; + self.scalar_len = 1; + self.scalar_of = Some(slot); + } + _ => {} + } + } + + /// The scalar being read, ended. + fn end_scalar(&mut self) { + let Some(slot) = self.scalar_of.take() else { + return; + }; + let raw = &self.scalar[..self.scalar_len]; + match slot { + Slot::Error => self.error = Some(raw != b"null"), + Slot::Count(i) => { + self.members[i] = if self.scalar_len > SCALAR_CAP - 1 { + Member::Unreadable + } else { + whole(raw).map_or(Member::Unreadable, Member::Whole) + }; + } + _ => {} + } + } + + /// A member name, read whole. + fn end_key(&mut self) { + let key = &self.key[..self.key_len.min(KEY_CAP)]; + let named = if self.key_len > KEY_CAP { + Slot::Other + } else if self.depth == 1 { + match key { + b"error" => Slot::Error, + k if k == self.usage().parent => Slot::Usage, + _ => Slot::Other, + } + } else { + self.usage() + .counted + .iter() + .position(|(name, _)| *name == key) + .map_or(Slot::Other, Slot::Count) + }; + if let 1 | 2 = self.depth { + self.at[self.depth as usize] = named; + } + } + + /// One byte of the answer. + fn byte(&mut self, b: u8) { + if self.in_str { + if self.escaped { + self.escaped = false; + } else if b == b'"' { + self.in_str = false; + if self.str_key { + self.end_key(); + } + return; + } else if b == b'\\' { + self.escaped = true; + } + if self.str_key { + if self.key_len < KEY_CAP { + self.key[self.key_len] = b; + } + self.key_len = self.key_len.saturating_add(1); + } + return; + } + if self.scalar_of.is_some() { + if !matches!( + b, + b' ' | b'\t' | b'\n' | b'\r' | b',' | b'}' | b']' | b':' | b'"' | b'{' | b'[' + ) { + if self.scalar_len < SCALAR_CAP { + self.scalar[self.scalar_len] = b; + self.scalar_len += 1; + } + return; + } + self.end_scalar(); + } + match b { + b' ' | b'\t' | b'\n' | b'\r' | b':' => {} + b'"' => { + self.in_str = true; + self.escaped = false; + self.str_key = self.in_object() && self.want_key; + if self.str_key { + self.key_len = 0; + self.want_key = false; + } else { + self.value(None); + } + } + b'{' | b'[' => { + self.value(None); + self.depth = self.depth.saturating_add(1); + if let 1 | 2 = self.depth { + self.object[self.depth as usize] = b == b'{'; + self.at[self.depth as usize] = Slot::Other; + } + self.want_key = b == b'{'; + } + b'}' | b']' => { + self.depth = self.depth.saturating_sub(1); + self.want_key = false; + } + b',' => self.want_key = self.in_object(), + _ => self.value(Some(b)), + } + } + + /// The reading as the answer stands: a scalar the answer ended in is read as it is. + fn settled(&self) -> (Option, [Member; 3]) { + let mut done = self.clone(); + done.end_scalar(); + (done.error, done.members) + } + + /// Whether the answer, with the far end's `status`, is a SUCCESS: a `2xx` status and no + /// `/error` member (a `null` one is none). The one success rule every count below reads. #[must_use] - pub fn fee(&self, status: Option) -> Option { - let success = status.is_some_and(|s| (200..300).contains(&s)); - (success && !codec::has(&self.body, PTR_ERROR)).then_some(Units { - class: tail::CLASS_FEE_INDEX, + pub fn success(&self, status: Option) -> bool { + status.is_some_and(|s| (200..300).contains(&s)) && self.settled().0 != Some(true) + } + + /// WHAT THE ANSWER BILLS, read at the last piece (THE DESIGN section 7, the owner money rulings): + /// a non-success bills nothing; a success bills one `decision`, or the whole count its + /// `/usage/units` reports (the hosted provider's count, DECISIONS D9), its `/usage/input_tokens` + /// and `/usage/output_tokens` where it reports them, and the request fee (`1` of + /// [`tail::FEE_PER_REQUEST`]: a refused or failed unit owes none). + /// + /// # Errors + /// + /// [`Unreadable`]: a success whose usage member is present but is not a whole count a signed + /// 64-bit count holds (negative, fractional, a string, `null`, past `i64::MAX`). + pub fn counts(&self, status: Option) -> Result { + if !self.success(status) { + return Ok([None; 4]); + } + let (_, members) = self.settled(); + let usage = self.usage(); + let mut whole = [None; 3]; + for (i, (_, pointer)) in usage.counted.iter().enumerate() { + whole[i] = match members[i] { + Member::Absent if i == 0 && usage.required => return Err(Unreadable(pointer)), + Member::Absent => None, + Member::Whole(n) => Some(n), + Member::Unreadable => return Err(Unreadable(pointer)), + }; + } + let reported = |class: u32, amount: u64| Units { + class, reported: true, - amount: 1, - }) + amount, + }; + Ok([ + Some(reported(tail::CLASS_DECISION_INDEX, whole[0].unwrap_or(1))), + whole[1].map(|n| reported(tail::CLASS_INPUT_TOKENS_INDEX, n)), + whole[2].map(|n| reported(tail::CLASS_OUTPUT_TOKENS_INDEX, n)), + Some(reported(tail::CLASS_FEE_INDEX, 1)), + ]) } } +/// A usage member's raw bytes read as a whole count a signed 64-bit count holds: ASCII digits +/// alone, at most `i64::MAX`. +#[must_use] +pub fn whole(raw: &[u8]) -> Option { + if raw.is_empty() || !raw.iter().all(u8::is_ascii_digit) { + return None; + } + core::str::from_utf8(raw) + .ok()? + .parse::() + .ok() + .filter(|n| i64::try_from(*n).is_ok()) +} + /// THE CALLER FIELDS THE FAR END NEVER RECEIVES: the ones the plane writes itself (the document /// type), the ones naming the hop (`host`), and the credential carriers (the far end is presented /// the member's credential by the kernel, never the caller's). Compared without case. Hop-by-hop @@ -302,23 +758,67 @@ pub const NOT_RELAYED: &[&str] = &[ "proxy-authorization", ]; +/// The caller's fields the far end receives, held in one buffer per unit: each field's name +/// (lowercased) and value, back to back, and where each ends. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct Relayed { + bytes: Vec, + /// Per field, `(end of its name, end of its value)` in `bytes`. + ends: Vec<(usize, usize)>, +} + +impl Relayed { + /// Every field, `(name, value)`, in the caller's order. + pub fn iter(&self) -> impl Iterator + '_ { + let mut from = 0; + self.ends.iter().map(move |&(name, value)| { + let field = (&self.bytes[from..name], &self.bytes[name..value]); + from = value; + field + }) + } + + /// Whether no field is relayed. + #[must_use] + pub fn is_empty(&self) -> bool { + self.ends.is_empty() + } +} + +/// Whether the caller's field `name` reaches the far end. +fn relayed(name: &[u8]) -> bool { + !NOT_RELAYED + .iter() + .any(|n| name.eq_ignore_ascii_case(n.as_bytes())) +} + /// THE CALLER'S FIELDS THE FAR END RECEIVES, in the order the caller sent them, a repeated name /// keeping every value: every field the caller sent but [`NOT_RELAYED`]'s (a same-dialect relay /// passes every header but the governed set; ARCHITECT DEC-SERVE Q2, the OWNER's DIALECT-FIDELITY -/// F2). Names are lowercased, as the wire compares them. +/// F2). Names are lowercased, as the wire compares them. Kept in one buffer sized once. #[must_use] -pub fn relayed_fields<'f>( - caller: impl IntoIterator, -) -> Vec<(Vec, Vec)> { - caller - .into_iter() - .filter(|(name, _)| { - !NOT_RELAYED - .iter() - .any(|n| name.eq_ignore_ascii_case(n.as_bytes())) - }) - .map(|(name, value)| (name.to_ascii_lowercase(), value.to_vec())) - .collect() +pub fn relayed_fields<'f, I>(caller: I) -> Relayed +where + I: IntoIterator, + I::IntoIter: Clone, +{ + let fields = caller.into_iter(); + let (mut size, mut count) = (0, 0); + for (name, value) in fields.clone().filter(|(name, _)| relayed(name)) { + size += name.len() + value.len(); + count += 1; + } + let mut kept = Relayed { + bytes: Vec::with_capacity(size), + ends: Vec::with_capacity(count), + }; + for (name, value) in fields.filter(|(name, _)| relayed(name)) { + kept.bytes.extend(name.iter().map(u8::to_ascii_lowercase)); + let name_end = kept.bytes.len(); + kept.bytes.extend_from_slice(value); + kept.ends.push((name_end, kept.bytes.len())); + } + kept } /// Render a refusal the kernel decided, in this dialect's error shape: `{"error": {"code", "message"}}`. diff --git a/crates/busbar-plane-decisions/src/ops.rs b/crates/busbar-plane-decisions/src/ops.rs index 7fa02b295a..947b2f9045 100644 --- a/crates/busbar-plane-decisions/src/ops.rs +++ b/crates/busbar-plane-decisions/src/ops.rs @@ -17,8 +17,12 @@ pub const OP_SYSTEMONE: OpClassId = OpClassId::new("systemone"); /// catalogue synthesis: this is typesafe's list, and only this plane may serve it. pub const OP_MODELS: OpClassId = OpClassId::new("models"); +/// `POST /api/evaluate` — the decision call of the second dialect, `nanojev` (THE DESIGN section 2: +/// "`/api/evaluate` is its second exact claim"). +pub const OP_EVALUATE: OpClassId = OpClassId::new("evaluate"); + /// Every operation class this plane's units can be, in declaration order. -pub const OP_CLASSES: &[OpClassId] = &[OP_SYSTEMONE, OP_MODELS]; +pub const OP_CLASSES: &[OpClassId] = &[OP_SYSTEMONE, OP_MODELS, OP_EVALUATE]; /// The path jev's decision call is mounted on. pub const PATH_SYSTEMONE: &str = "/v1/systemone"; @@ -26,6 +30,9 @@ pub const PATH_SYSTEMONE: &str = "/v1/systemone"; /// The path jev's model catalogue is mounted on. pub const PATH_MODELS: &str = "/v1/models"; +/// The path the `nanojev` dialect's decision call is mounted on. +pub const PATH_EVALUATE: &str = "/api/evaluate"; + /// One row of the method table: which `(verb, path)` is which operation, and whether the request /// carries a body this plane must wait for. #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -55,6 +62,12 @@ pub const METHODS: &[MethodRow] = &[ path: PATH_MODELS, has_request_body: false, }, + MethodRow { + op: OP_EVALUATE, + verb: "POST", + path: PATH_EVALUATE, + has_request_body: true, + }, ]; /// Which row a `(verb, path)` pair names, where one does. diff --git a/crates/busbar-plane-decisions/src/plane.rs b/crates/busbar-plane-decisions/src/plane.rs index 0df633c4b6..152888403e 100644 --- a/crates/busbar-plane-decisions/src/plane.rs +++ b/crates/busbar-plane-decisions/src/plane.rs @@ -197,9 +197,9 @@ impl Plane for DecisionPlane { let facts = self.draft_facts(row); Ok(Ingress::OneShot(Box::new(UnitDraft { op: row.op, - // The request body is never read for a declared pointer — see `codec::REQUEST_PTRS`'s - // own note on why that list is empty. The view still carries the WHOLE body, because - // byte-identity forwarding reads `Ir::body()`, never a pointer. + // The request body is read at the declared pointers alone (`codec::REQUEST_PTRS`, the + // top-level `model`). The view still carries the WHOLE body, because byte-identity + // forwarding reads `Ir::body()`, never a pointer. body_ir: codec::view(body, REQUEST_PTRS, ctx)?, correlates: None, correlation_out: None, diff --git a/crates/busbar-plane-decisions/src/plane_door.rs b/crates/busbar-plane-decisions/src/plane_door.rs index ce5d1606da..4c4ab49c36 100644 --- a/crates/busbar-plane-decisions/src/plane_door.rs +++ b/crates/busbar-plane-decisions/src/plane_door.rs @@ -17,18 +17,29 @@ //! * The Statement: the plane's key and version, `decisions:` declared and `providers:` consumed, //! its one outbound need, and the tail [`TAIL`] (every list read off [`crate::driven::tail`]). //! * `validate`, `open`, `refresh`: the settings blob read as the `decisions:` section -//! ([`crate::config::DecisionsSection`]); a generation's snapshot claims -//! [`crate::driven::served_claims`] for its model count, lists every model the section -//! configures (the kernel filters them by scope and `/v1/models` appends them), and binds no -//! audience (the operation is served on the plain data plane to a keyed caller). `retire` drops -//! a generation. -//! * `arrive`: [`crate::driven::arrive`]; an unclaimed request is refused at 404. +//! ([`crate::config::DecisionsSection`]), each model's dialect resolved ([`routes`]: its own +//! `protocol`, else its provider's as the host stated it at `open`); a generation's snapshot +//! claims [`crate::driven::served_claims`] for its models' dialects, lists every model the +//! section configures (the kernel filters them by scope and `/v1/models` appends them), and +//! binds no audience (the operation is served on the plain data plane to a keyed caller). +//! `retire` drops a generation. +//! * `arrive`: [`crate::driven::arrive`], routed over the model the request names +//! ([`crate::driven::route_model`]); an unclaimed request is refused at 404, and one that routes +//! over no model at its [`crate::driven::Unrouted`] status. //! * `on_piece`: the ATTEMPT's request head ([`crate::driven::attempt_request`]), the caller's body to the -//! far end unchanged ([`crate::driven::caller_piece`]), and the far end's answer relayed unchanged -//! with its count read at the last piece ([`crate::driven::FarEndReading`]). -//! * `refusal`: [`crate::driven::refusal_body`]. +//! far end unchanged ([`crate::driven::caller_piece`]) but for the routed model's `upstream_model` +//! ([`crate::driven::spliced`]), and the far end's answer relayed unchanged with its count read at +//! the last piece ([`crate::driven::FarEndReading`]). +//! * `refusal`: [`crate::driven::refusal_body`]; the refused unit's state is dropped. +//! * `cancel`: the unit on the cancelled ticket is dropped, `CANCEL_ABORTED` before its far end +//! answered and `CANCEL_OK_PARTIAL` after. //! * `serve` and `project` are REFUSED: the plane publishes no admin route and binds no hook view. -//! `hydrate`, `start`, `tick`, `drive`, `cancel`, `release` and `close` hold nothing. +//! `hydrate`, `start`, `tick`, `drive`, `release` and `close` hold nothing. +//! +//! A unit's state lives from its arrival to its end (its last far-end piece, its refusal or its +//! cancel), and at most [`MAX_UNITS`] live at once: an arrival past that is refused, and a live +//! unit is never dropped to make room (THE DESIGN section 11.2: memory is plugin-owned, and no +//! state grows without bound on the hot path). use std::collections::BTreeMap; use std::mem::size_of; @@ -44,13 +55,15 @@ use busbar_contract::abi::mechanism::door::{ use busbar_contract::abi::mechanism::lifecycle::{ GenIn, RefreshIn, ReleaseIn, TickIn, TickOut, ValidateIn, }; +use busbar_contract::abi::mechanism::ticket::Ticket; use busbar_contract::abi::plane::{ ArriveIn, ArriveOut, BillableClass, DialectAuth, OnPieceIn, OnPieceOut, OpClass, OutField, PlaneDriveIn, PlaneDriveOut, PlaneOpenIn, PlaneOpenOut, PlaneRefreshOut, PlaneSnapshot, PlaneTail, ProjectIn, ProjectOut, RefusalIn, RefusalOut, ServeIn, ServeOut, UnitCount, - CANCEL_ABORTED, CLAIM_EXACT, EMIT_DONE, EMIT_TO_FAR_END, FROM_CALLER, FROM_FAR_END, - FROM_KERNEL, INGRESS_REQUEST_RESPONSE, PIECE_HAS_STATUS, PIECE_LAST, PRINCIPAL_NONE, - PRINCIPAL_OPTIONAL, PRINCIPAL_REQUIRED, ROUTE_DIRECT, SHAPE_WHOLE, UNITS_REPORTED, + CANCEL_ABORTED, CANCEL_OK_PARTIAL, CLAIM_EXACT, EMIT_DONE, EMIT_TO_FAR_END, FROM_CALLER, + FROM_FAR_END, FROM_KERNEL, INGRESS_REQUEST_RESPONSE, PIECE_HAS_STATUS, PIECE_LAST, + PRINCIPAL_NONE, PRINCIPAL_OPTIONAL, PRINCIPAL_REQUIRED, ROUTE_DIRECT, SHAPE_WHOLE, + UNITS_REPORTED, }; use busbar_contract::abi::plane::{PlaneCancelIn, PlaneCancelOut}; use busbar_contract::abi::sdk::door::{abi_str, statement}; @@ -72,7 +85,8 @@ pub const VERSION: &str = "1.6.0"; /// The most calls the kernel keeps in flight on one instance. const MAX_INFLIGHT: u32 = 64; -/// The most units the instance keeps state for at once; past it, the oldest is dropped first. +/// The most units the instance keeps state for at once; past it, a new arrival is refused +/// ([`BUSY`]) and no live unit is dropped. pub const MAX_UNITS: usize = 4096; /// [`ArriveOut::refusal`]: the request names no operation this plane claims. @@ -81,6 +95,16 @@ pub const UNCLAIMED: u32 = 1; /// The status an unclaimed request is refused at. const STATUS_NOT_FOUND: u32 = 404; +/// [`ArriveOut::refusal`]: the request routes over no configured model ([`driven::Unrouted`]). +pub const UNROUTED: u32 = 2; + +/// [`ArriveOut::refusal`]: the instance holds [`MAX_UNITS`] live units; the arrival is refused at +/// [`STATUS_BUSY`], about the entry it routed over, before anything is charged. +pub const BUSY: u32 = 3; + +/// The status a [`BUSY`] arrival is refused at. +const STATUS_BUSY: u32 = 503; + /// The human label. const LABEL: &str = "Decisions"; @@ -105,33 +129,42 @@ const SECTIONS: &[Section] = &[ }, ]; -const DIALECTS: &[AbiStr] = &[abi_str(tail::DIALECTS[0])]; +const DIALECTS: &[AbiStr] = &[abi_str(tail::DIALECTS[0]), abi_str(tail::DIALECTS[1])]; -/// The dialect's default outbound style ([`tail::DIALECT_AUTH`]). -const DIALECT_AUTH: &[DialectAuth] = &[DialectAuth { - dialect: tail::DIALECT_AUTH[0].0, - _reserved: 0, - style: abi_str(tail::DIALECT_AUTH[0].1), - params: Blob::ABSENT, -}]; +/// Each dialect's default outbound style ([`tail::DIALECT_AUTH`]). +const DIALECT_AUTH: &[DialectAuth] = &[dialect_auth(0), dialect_auth(1)]; + +/// [`tail::DIALECT_AUTH`]'s row `i`, on the ABI. +const fn dialect_auth(i: usize) -> DialectAuth { + DialectAuth { + dialect: tail::DIALECT_AUTH[i].0, + _reserved: 0, + style: abi_str(tail::DIALECT_AUTH[i].1), + params: Blob::ABSENT, + } +} const SCOPE_KINDS: &[AbiStr] = &[abi_str(tail::SCOPE_KINDS[0])]; -const OP_CLASSES: &[OpClass] = &[OpClass { - op: abi_str(tail::OP_CLASSES[0].as_str()), - name: abi_str(tail::OP_CLASSES[0].as_str()), -}]; +const OP_CLASSES: &[OpClass] = &[op_class(0), op_class(1)]; -const BILLABLE_CLASSES: &[BillableClass] = &[ - BillableClass { - class: abi_str(tail::BILLABLE_CLASSES[0].0.as_str()), - family: abi_str(tail::BILLABLE_CLASSES[0].1), - }, +/// [`tail::OP_CLASSES`]' class `i`, on the ABI. +const fn op_class(i: usize) -> OpClass { + OpClass { + op: abi_str(tail::OP_CLASSES[i].as_str()), + name: abi_str(tail::OP_CLASSES[i].as_str()), + } +} + +const BILLABLE_CLASSES: &[BillableClass] = &[billable(0), billable(1), billable(2), billable(3)]; + +/// [`tail::BILLABLE_CLASSES`]' class `i`, on the ABI. +const fn billable(i: usize) -> BillableClass { BillableClass { - class: abi_str(tail::BILLABLE_CLASSES[1].0.as_str()), - family: abi_str(tail::BILLABLE_CLASSES[1].1), - }, -]; + class: abi_str(tail::BILLABLE_CLASSES[i].0.as_str()), + family: abi_str(tail::BILLABLE_CLASSES[i].1), + } +} /// The fee unit the plane counts ([`tail::FEE_UNITS`]). const FEE_UNITS: &[AbiStr] = &[abi_str(tail::FEE_UNITS[0])]; @@ -272,31 +305,83 @@ pub fn read_dealt(settings: &[u8]) -> Result { } } -/// ONE GENERATION'S SNAPSHOT for a section with `models` configured models: the claims -/// [`driven::served_claims`] serves, each an exact target over the claim's transport. It binds no -/// audience: the operation is served on the plain data plane to a keyed caller, as the llm plane's -/// is, and its refusals are this dialect's. +/// ONE GENERATION'S SNAPSHOT for a section whose models are of `dialects`: the claims +/// [`driven::served_claims`] serves, each an exact target over the claim's transport, wearing its +/// own dialect for a refusal before `arrive`. It binds no audience: the operation is served on the +/// plain data plane to a keyed caller, as the llm plane's is. #[must_use] -pub fn snapshot_spec(models: usize) -> SnapshotSpec { +pub fn snapshot_spec(dialects: &[u32]) -> SnapshotSpec { SnapshotSpec { - claims: driven::served_claims(models) - .iter() - .map(|(verb, target)| ClaimSpec::new(verb, target, claims::TRANSPORT, CLAIM_EXACT)) + claims: driven::served_claims(dialects) + .into_iter() + .map(|(verb, target, dialect)| ClaimSpec { + refusal_dialect: u16::try_from(dialect).unwrap_or(0), + ..ClaimSpec::new(verb, target, claims::TRANSPORT, CLAIM_EXACT) + }) .collect(), ..SnapshotSpec::default() } } -/// ONE GENERATION'S SNAPSHOT for `section`: [`snapshot_spec`]'s claims for its model count, and -/// every model it configures LISTED (THE DESIGN section 2: all configured decisions models are -/// listed, scope-filtered by the kernel; `/v1/models` appends them), in ascending name order. +/// What a claimed arrival routes over: a model's dialect, and its `upstream_model`. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Routed { + /// Its index in [`tail::DIALECTS`]. + pub dialect: u32, + /// Its `upstream_model`. + pub upstream: Option, +} + +/// EVERY MODEL'S ROUTE under `section`: its dialect, its own `protocol` override or else its +/// provider's protocol (`protocols`, the provider facts the host handed at `open`; THE DESIGN +/// section 2: "a model's dialect is its override or its provider's protocol"), `jev` for a provider +/// the host stated no protocol for. +/// +/// # Errors +/// +/// A model whose dialect is none this plane speaks, named with the dialects it does (#51: an +/// unknown dialect fails closed). +pub fn routes( + section: &DecisionsSection, + protocols: &BTreeMap, +) -> Result, String> { + section + .models + .iter() + .map(|(name, model)| { + let protocol = model + .protocol + .as_deref() + .or_else(|| protocols.get(&model.provider).map(String::as_str)) + .unwrap_or(tail::DIALECTS[0]); + let dialect = driven::dialect_of(protocol).ok_or_else(|| { + format!( + "decisions.models.{name} resolves to protocol '{protocol}', but the decisions \ + plane speaks only: {}.", + tail::DIALECTS.join(", ") + ) + })?; + Ok(( + name.clone(), + Routed { + dialect, + upstream: model.upstream_model.clone(), + }, + )) + }) + .collect() +} + +/// ONE GENERATION'S SNAPSHOT for `section`'s `routes`: [`snapshot_spec`]'s claims for its models' +/// dialects, and every model it configures LISTED (THE DESIGN section 2: all configured decisions +/// models are listed, scope-filtered by the kernel; `/v1/models` appends them), in ascending name +/// order. #[must_use] -pub fn generation_spec(section: &DecisionsSection) -> SnapshotSpec { - let mut listed: Vec = section.models.keys().cloned().collect(); - listed.sort(); +pub fn generation_spec(routes: &BTreeMap) -> SnapshotSpec { + let dialects: Vec = routes.values().map(|r| r.dialect).collect(); SnapshotSpec { - listed, - ..snapshot_spec(section.models.len()) + listed: routes.keys().cloned().collect(), + ..snapshot_spec(&dialects) } } @@ -317,9 +402,16 @@ struct Unit { owed_flags: u32, /// The caller's fields the far end receives ([`driven::relayed_fields`]), kept from `arrive` /// (the caller's head crosses once) for every ATTEMPT of the unit. - caller: Vec<(Vec, Vec)>, + caller: driven::Relayed, + /// The ticket the unit's last piece crossed on: what a `cancel` names it by. + ticket: Option, + /// Its place in the instance's arrivals ([`DecisionsDoor::arrivals`]). + seq: u64, /// The far end's status, read off its first piece; the request fee is judged on it. status: Option, + /// The routed model's `upstream_model`, kept from `arrive`: the caller's top-level `model` is + /// spliced to it on the way to the far end where the two differ ([`driven::spliced`]). + upstream: Option, } /// One instance: every live generation's snapshot, and the units in flight. @@ -327,28 +419,21 @@ struct Unit { pub struct DecisionsDoor { generations: Generations, units: Keyed, - /// The entry a claimed arrival routes over, directly: the one configured model of the latest - /// generation (none held when the section configures other than one, and nothing is claimed). - routed: Keyed<(), String>, + /// The models a claimed arrival routes over, directly, by name: every model the latest + /// generation configures, each with its dialect and `upstream_model`. + models: Keyed, + /// How many arrivals the instance has kept: each unit's [`Unit::seq`]. The count is the + /// SDK's per-instance keyed state, its one entry under `()`: the plane holds no interior + /// state of its own. + arrivals: Keyed<(), u64>, + /// Each provider's protocol, as the host stated it at `open` (`PlaneOpenIn::providers`). + protocols: BTreeMap, } impl DecisionsDoor { - /// Route claimed arrivals over `section`'s one model, or none. - fn route_over(&self, section: &DecisionsSection) { - match routed_model(section) { - Some(model) => self.routed.insert((), model), - None => self.routed.remove(&()), - }; - } -} - -/// The entry a claimed arrival routes over under `section`: its one model, when it configures -/// exactly one ([`driven::served_claims`]' rule); `None` otherwise. -fn routed_model(section: &DecisionsSection) -> Option { - let mut models = section.models.keys(); - match (models.next(), models.next()) { - (Some(one), None) => Some(one.clone()), - _ => None, + /// Route claimed arrivals over `routes`. + fn route_over(&self, routes: BTreeMap) { + self.models.with_all(|models| *models = routes); } } @@ -372,7 +457,11 @@ macro_rules! slot { slot!( /// `validate`: the settings read as the section, or refused in the grammar's words. Validate, ValidateIn, OutHead, |_, input, mut out| { - match read_dealt(input.field(|i| &i.settings).bytes()) { + // A model's own `protocol` must be a dialect this plane speaks; its provider's is judged + // by the kernel against the dialects the Statement states. + match read_dealt(input.field(|i| &i.settings).bytes()) + .and_then(|section| routes(§ion, &BTreeMap::new())) + { Ok(_) => Outcome::Ready, Err(words) => out.fail(Refusal::refused(words)), } @@ -387,13 +476,29 @@ slot!( Ok(section) => section, Err(words) => return open_failed(open, &mut out, |o| &o.open.err_len, &words), }; + let protocols: BTreeMap = input + .providers() + .iter() + .filter_map(|p| { + Some(( + p.field(|p| &p.name).as_str().ok()?.to_string(), + p.field(|p| &p.protocol).as_str().ok()?.to_string(), + )) + }) + .collect(); + let routed = match routes(§ion, &protocols) { + Ok(routed) => routed, + Err(words) => return open_failed(open, &mut out, |o| &o.open.err_len, &words), + }; let plane = DecisionsDoor { generations: Generations::new(), units: Keyed::new(), - routed: Keyed::new(), + models: Keyed::new(), + arrivals: Keyed::new(), + protocols, }; - plane.route_over(§ion); - let spec = generation_spec(§ion); + let spec = generation_spec(&routed); + plane.route_over(routed); out.publish(|o| &o.snapshot, &plane.generations, open.generation, &spec); instance.open(plane); Outcome::Ready @@ -410,8 +515,12 @@ slot!( Ok(section) => section, Err(words) => return out.fail(Refusal::refused(words)), }; - let spec = generation_spec(§ion); - plane.route_over(§ion); + let routed = match routes(§ion, &plane.protocols) { + Ok(routed) => routed, + Err(words) => return out.fail(Refusal::refused(words)), + }; + let spec = generation_spec(&routed); + plane.route_over(routed); out.publish(|o| &o.snapshot, &plane.generations, input.generation, &spec); Outcome::Ready } @@ -441,9 +550,24 @@ slot!( ); slot!( - /// `cancel`: a unit is never answered before its far end has, so nothing is moved. - Cancel, PlaneCancelIn, PlaneCancelOut, |_, _, mut out| { - out.set(|o| &o.cancel.disposition, CANCEL_ABORTED); + /// `cancel`: the unit on the cancelled ticket is dropped. Before its far end answered it was + /// aborted (nothing billed); after, it was cut partway. + Cancel, PlaneCancelIn, PlaneCancelOut, |instance, input, mut out| { + let ticket = input.get().cancel.ticket; + let answered = instance.get().is_some_and(|plane| { + plane.units.with_all(|units| { + let key = units + .iter() + .find(|(_, unit)| unit.ticket == Some(ticket)) + .map(|(key, _)| *key); + key.and_then(|key| units.remove(&key)) + .is_some_and(|unit| unit.status.is_some()) + }) + }); + out.set( + |o| &o.cancel.disposition, + if answered { CANCEL_OK_PARTIAL } else { CANCEL_ABORTED }, + ); Outcome::Ready } ); @@ -459,8 +583,9 @@ slot!( ); slot!( - /// `arrive`: a claimed request's op class, principal need, dialect and route (its one model, - /// directly: ARCHITECT Q-SW6/Q-FL3); an unclaimed one is refused at 404. + /// `arrive`: a claimed request's op class, principal need, dialect and route (the model it + /// names, or the only one configured, directly: ARCHITECT Q-SW6/Q-FL3); an unclaimed one is + /// refused at 404, and one that routes over no model at its [`driven::Unrouted`] status. Arrive, ArriveIn, ArriveOut, |instance, input, mut out| { let verb = input.field(|i| &i.method).as_str().unwrap_or_default(); let target = input.field(|i| &i.target).as_str().unwrap_or_default(); @@ -480,9 +605,22 @@ slot!( }, ); if let Some(plane) = instance.get() { - if let Some(model) = plane.routed.get(&()) { - out.route(ROUTE_DIRECT, &model); - } + let body = input.field(|i| &i.body).bytes(); + let routed = plane.models.with_all(|models| { + driven::route_model(body, models, |r| r.dialect == arrived.dialect) + .map(|(name, routed)| (name.to_string(), routed.upstream.clone())) + }); + let upstream = match routed { + Ok((model, upstream)) => { + out.route(ROUTE_DIRECT, &model); + upstream + } + Err(unrouted) => { + out.set(|o| &o.refusal, UNROUTED); + out.set(|o| &o.refusal_status, u32::from(unrouted.status())); + return out.fail(Refusal::refused(unrouted.text())); + } + }; // The caller's head crosses here once: keep what the far end receives of it. let caller = driven::relayed_fields(input.fields().iter().map(|f| { ( @@ -491,7 +629,31 @@ slot!( ) })); let unit = input.get().unit; - plane.units.with_all(|units| unit_of(units, unit).caller = caller); + let seq = plane.arrivals.with_all(|n| { + let next = n.entry(()).or_insert(0); + let seq = *next; + *next = seq.wrapping_add(1); + seq + }); + let kept = plane.units.with_all(|units| { + reclaim_stale(units, unit, seq); + let Some(kept) = unit_of(units, unit) else { + return false; + }; + kept.caller = caller; + kept.upstream = upstream; + kept.seq = seq; + true + }); + if !kept { + // Full: the arrival is refused, about the entry it routed over, and no live unit + // is dropped to make room. + out.set(|o| &o.refusal, BUSY); + out.set(|o| &o.refusal_status, STATUS_BUSY); + return out.fail(Refusal::refused( + "the decisions plane is serving as many requests as it holds; retry", + )); + } } Outcome::Ready } @@ -542,7 +704,8 @@ fn pay(unit: &mut Unit, input: Lent<'_, OnPieceIn>, out: &mut Out<'_, OnPieceOut done } -/// Owe `bytes` under `flags`, and pay what fits now. Answers whether the unit is done. +/// Owe `bytes` under `flags`: write what fits straight from `bytes`, and keep only what does not +/// fit for the re-calls `more = 1` asks for. Answers whether the unit is done. fn owe( unit: &mut Unit, bytes: &[u8], @@ -550,11 +713,18 @@ fn owe( input: Lent<'_, OnPieceIn>, out: &mut Out<'_, OnPieceOut>, ) -> bool { + let n = input.reply_buf().stream(bytes); unit.owed.clear(); - unit.owed.extend_from_slice(bytes); unit.at = 0; unit.owed_flags = flags; - pay(unit, input, out) + let more = n < bytes.len(); + if more { + unit.owed.extend_from_slice(&bytes[n..]); + } + out.set(|o| &o.emitted, n as u64); + out.set(|o| &o.more, u32::from(more)); + out.set(|o| &o.flags, if more { flags & !EMIT_DONE } else { flags }); + !more && flags & EMIT_DONE != 0 } /// An ATTEMPT: the request bound for the far end, its verb, target and dialect fields. A new @@ -566,7 +736,8 @@ fn attempt_piece( input: Lent<'_, OnPieceIn>, out: &mut Out<'_, OnPieceOut>, ) -> Outcome { - let request = driven::attempt_request(&[]); + let dialect = input.get().dialect; + let request = driven::attempt_request(dialect, &[]); let (mut fields, units, mut arena) = (input.fields_buf(), input.units_buf(), input.arena_buf()); let verb = arena.span(request.verb.as_bytes()); let target = arena.span(request.target.as_bytes()); @@ -576,7 +747,7 @@ fn attempt_piece( value: arena.span(value), }); } - for (name, value) in &unit.caller { + for (name, value) in unit.caller.iter() { fields.push(OutField { name: arena.span(name), value: arena.span(value), @@ -588,8 +759,14 @@ fn attempt_piece( *unit = Unit { attempt: true, caller: std::mem::take(&mut unit.caller), + upstream: unit.upstream.take(), + ticket: unit.ticket, + seq: unit.seq, + owed: std::mem::take(&mut unit.owed), + reading: FarEndReading::for_dialect(dialect), ..Unit::default() }; + unit.owed.clear(); out.set(|o| &o.verb, verb); out.set(|o| &o.target, target); out.set(|o| &o.flags, EMIT_TO_FAR_END); @@ -628,10 +805,20 @@ fn far_end( } } if last { - for count in [unit.reading.units(), unit.reading.fee(unit.status)] - .into_iter() - .flatten() - { + // THE MONEY RULE (THE DESIGN section 7): a success bills its decision count, its tokens and + // its fee; a usage member that is not a whole count fails the unit loudly, never a silent 0. + let counts = match unit.reading.counts(unit.status) { + Ok(counts) => counts, + Err(driven::Unreadable(pointer)) => { + return ( + out.fail(Refusal::failed(format!( + "the far end's answer carries `{pointer}`, which is not a whole count" + ))), + true, + ); + } + }; + for count in counts.into_iter().flatten() { units.push(UnitCount { class: count.class, source: UNITS_REPORTED, @@ -651,14 +838,30 @@ fn far_end( (Outcome::Ready, done) } -/// Keep `unit`'s state in `units`, dropping the smallest (oldest) keys first past [`MAX_UNITS`]. -fn unit_of(units: &mut BTreeMap, key: u64) -> &mut Unit { - while units.len() >= MAX_UNITS && !units.contains_key(&key) { - if units.pop_first().is_none() { - break; - } +/// At [`MAX_UNITS`], the oldest unit that arrived [`MAX_UNITS`] or more arrivals before `seq` and +/// never crossed a piece is reclaimed: the kernel never started it, and keeping it would leave the +/// instance refusing every arrival for good. A unit that crossed a piece is never reclaimed. +fn reclaim_stale(units: &mut BTreeMap, key: u64, seq: u64) { + if units.len() < MAX_UNITS || units.contains_key(&key) { + return; } - units.entry(key).or_default() + let stale = units + .iter() + .filter(|(_, u)| u.ticket.is_none() && seq.saturating_sub(u.seq) >= MAX_UNITS as u64) + .min_by_key(|(_, u)| u.seq) + .map(|(k, _)| *k); + if let Some(stale) = stale { + units.remove(&stale); + } +} + +/// `unit`'s state in `units`, a new entry when it has none; `None` when it has none and +/// [`MAX_UNITS`] live already (a live unit is never dropped to make room). +fn unit_of(units: &mut BTreeMap, key: u64) -> Option<&mut Unit> { + if units.len() >= MAX_UNITS && !units.contains_key(&key) { + return None; + } + Some(units.entry(key).or_default()) } slot!( @@ -671,7 +874,10 @@ slot!( let given = input.get(); let bytes = input.field(|i| &i.bytes).bytes(); plane.units.with_all(|units| { - let unit = unit_of(units, given.unit); + let Some(unit) = unit_of(units, given.unit) else { + return Outcome::Refused; + }; + unit.ticket = Some(given.head.ticket); // A re-call after `more = 1` carries no bytes, no flags and no attempt: it is paid from // what is owed. let continues = bytes.is_empty() && given.flags == 0 && given.attempt_no == 0; @@ -685,7 +891,9 @@ slot!( FROM_CALLER => match driven::caller_piece(bytes, given.flags & PIECE_LAST != 0) { CallerAnswer::Empty => (Outcome::Refused, true), CallerAnswer::Keep if unit.attempt => { - owe(unit, bytes, EMIT_TO_FAR_END, input, &mut out); + let spliced = + unit.upstream.as_deref().and_then(|up| driven::spliced(bytes, up)); + owe(unit, spliced.as_deref().unwrap_or(bytes), EMIT_TO_FAR_END, input, &mut out); (Outcome::Ready, false) } CallerAnswer::Keep => (Outcome::Ready, false), @@ -706,8 +914,14 @@ slot!( /// `refusal`: the kernel's status and text in this dialect's error shape, as JSON, and the /// wait the kernel handed beside it (`retry_after_s`, a limit's rolling window or the walk's /// floor) as `Retry-After`, as every plane answers it. - RefusalSlot, RefusalIn, RefusalOut, |_, input, mut out| { + RefusalSlot, RefusalIn, RefusalOut, |instance, input, mut out| { let given = input.get(); + // The refusal ends the unit: its state goes with it. + if let Some(plane) = instance.get() { + if given.unit != 0 { + plane.units.remove(&given.unit); + } + } let status = u16::try_from(given.status).unwrap_or(0); let text = input.field(|i| &i.text).as_str().unwrap_or_default(); let body = driven::refusal_body(status, text); diff --git a/crates/busbar-plane-decisions/src/tests/codec.rs b/crates/busbar-plane-decisions/src/tests/codec.rs index e56cd634af..d5943b1e50 100644 --- a/crates/busbar-plane-decisions/src/tests/codec.rs +++ b/crates/busbar-plane-decisions/src/tests/codec.rs @@ -22,16 +22,25 @@ fn reads_the_declared_pointers_off_a_failure_body() { assert_eq!(read_u64(FAILURE, PTR_USAGE_UNITS), None); } +/// A request is read at its top-level `model` alone (DECISIONS D8b), never at `state`. #[test] -fn request_ptrs_is_empty() { - assert!(REQUEST_PTRS.is_empty()); +fn request_ptrs_is_the_model_alone() { + assert_eq!(REQUEST_PTRS, &[PTR_MODEL]); + assert_eq!( + read_str(br#"{"model":"jev","state":{}}"#, PTR_MODEL), + Some("jev") + ); } +/// No declared pointer passes through a `state` or `answers` member (a member merely spelled with +/// those letters, as NanoJev's `/execution/states`, is another member). #[test] fn response_ptrs_never_names_state_or_answers() { for p in RESPONSE_PTRS { - assert!(!p.contains("state")); - assert!(!p.contains("answers")); + assert!( + !p.split('/').any(|seg| seg == "state" || seg == "answers"), + "{p}" + ); } } diff --git a/crates/busbar-plane-decisions/src/tests/driven.rs b/crates/busbar-plane-decisions/src/tests/driven.rs index 4f5e6d64ac..fe073ef39c 100644 --- a/crates/busbar-plane-decisions/src/tests/driven.rs +++ b/crates/busbar-plane-decisions/src/tests/driven.rs @@ -9,12 +9,16 @@ fn systemone_arrives_as_the_first_op_class_with_a_principal() { assert_eq!(tail::OP_CLASSES[a.op_class as usize], ops::OP_SYSTEMONE); assert_eq!(a.principal, PrincipalNeed::Required); assert_eq!(tail::DIALECTS[a.dialect as usize], "jev"); + let e = arrive("POST", ops::PATH_EVALUATE).expect("evaluate is claimed"); + assert_eq!(e.op, ops::OP_EVALUATE); + assert_eq!(tail::OP_CLASSES[e.op_class as usize], ops::OP_EVALUATE); + assert_eq!(tail::DIALECTS[e.dialect as usize], "nanojev"); } #[test] fn models_is_not_claimed() { assert_eq!(arrive("GET", ops::PATH_MODELS), None); - assert!(!tail::CLAIMS.iter().any(|(_, t)| *t == ops::PATH_MODELS)); + assert!(!tail::CLAIMS.iter().any(|(_, t, _)| *t == ops::PATH_MODELS)); assert!(!tail::OP_CLASSES.contains(&ops::OP_MODELS)); } @@ -26,16 +30,28 @@ fn an_unknown_request_is_not_claimed() { #[test] fn every_claim_arrives() { - for (verb, target) in tail::CLAIMS { - assert!(arrive(verb, target).is_some(), "{verb} {target}"); + for (verb, target, dialect) in tail::CLAIMS { + let a = arrive(verb, target).unwrap_or_else(|| panic!("{verb} {target}")); + assert_eq!(a.dialect, *dialect, "{verb} {target}"); + assert_eq!(attempt_request(*dialect, b"{}").target, *target); } } +/// RED (audit plane-decisions#5): THE DESIGN section 7, "its card prices `decision`, `input_tokens` +/// and `output_tokens` (family `decision`, all three required, 0 = free)". #[test] fn the_tail_names_the_decision_class_at_its_index() { let (class, family) = tail::BILLABLE_CLASSES[tail::CLASS_DECISION_INDEX as usize]; assert_eq!(class, CLASS_DECISION); assert_eq!(family, "decision"); + for (index, name) in [ + (tail::CLASS_INPUT_TOKENS_INDEX, "input_tokens"), + (tail::CLASS_OUTPUT_TOKENS_INDEX, "output_tokens"), + ] { + let (class, family) = tail::BILLABLE_CLASSES[index as usize]; + assert_eq!((class.as_str(), family), (name, "decision")); + } + assert_eq!(tail::BILLABLE_CLASSES.len(), 4); // The request fee is the one fee unit, and it is a class at its own index. assert_eq!(tail::FEE_UNITS, &[busbar_contract::plane::PER_REQUEST]); let (fee, fee_family) = tail::BILLABLE_CLASSES[tail::CLASS_FEE_INDEX as usize]; @@ -43,7 +59,8 @@ fn the_tail_names_the_decision_class_at_its_index() { assert_eq!(fee_family, tail::FEE_FAMILY); assert_eq!(tail::SECTION_DECLARING, crate::config::SECTION); assert_eq!(tail::NEEDS, &[(crate::claims::TRANSPORT, "bearer")]); - assert_eq!(tail::DIALECT_AUTH, &[(0, "bearer")]); + assert_eq!(tail::DIALECT_AUTH, &[(0, "bearer"), (1, "bearer")]); + assert_eq!(tail::DIALECTS, &["jev", "nanojev"]); } #[test] @@ -56,7 +73,7 @@ fn the_caller_body_is_kept_and_an_empty_one_is_refused() { #[test] fn an_attempt_forwards_the_caller_body_unchanged() { let body = br#"{"state":{"session":"s"},"context":{}}"#; - let r = attempt_request(body); + let r = attempt_request(tail::DIALECT_JEV, body); assert_eq!(r.verb, "POST"); assert_eq!(r.target, "/v1/systemone"); assert_eq!(r.fields, [("content-type", &b"application/json"[..])]); @@ -67,6 +84,23 @@ fn an_attempt_forwards_the_caller_body_unchanged() { ); } +/// The answer's counts as `(class, amount)`, for a far end answering `status` with `body`. +fn billed(status: u32, body: &[u8]) -> Result, Unreadable> { + let mut reading = FarEndReading::new(); + reading.piece(body); + reading.counts(Some(status)).map(|c| { + c.into_iter() + .flatten() + .map(|u| (u.class, u.amount)) + .collect() + }) +} + +const DECISION: u32 = tail::CLASS_DECISION_INDEX; +const FEE: u32 = tail::CLASS_FEE_INDEX; +const INPUT: u32 = tail::CLASS_INPUT_TOKENS_INDEX; +const OUTPUT: u32 = tail::CLASS_OUTPUT_TOKENS_INDEX; + #[test] fn far_end_pieces_are_relayed_unchanged_and_the_count_read_over_the_whole_answer() { let mut reading = FarEndReading::new(); @@ -74,10 +108,11 @@ fn far_end_pieces_are_relayed_unchanged_and_the_count_read_over_the_whole_answer let b = br#"its":42},"answers":{"decision":"approve"}}"#; assert_eq!(reading.piece(a), &a[..]); assert_eq!(reading.piece(b), &b[..]); + let counts = reading.counts(Some(200)).expect("a whole count"); assert_eq!( - reading.units(), + counts[0], Some(Units { - class: 0, + class: DECISION, reported: true, amount: 42 }) @@ -85,56 +120,115 @@ fn far_end_pieces_are_relayed_unchanged_and_the_count_read_over_the_whole_answer } #[test] -fn an_error_answer_reports_no_units() { - let mut reading = FarEndReading::new(); - reading.piece(br#"{"error":{"code":"invalid_state"},"usage":{"units":3}}"#); - assert_eq!(reading.units(), None); +fn an_error_answer_bills_nothing() { + assert_eq!( + billed( + 200, + br#"{"error":{"code":"invalid_state"},"usage":{"units":3}}"# + ), + Ok(vec![]) + ); } -/// RED: the count is read as `decode_response` reads it. Success is the absence of an `/error` -/// member, whatever the status (a 4xx with no `/error` still reports its count, as predev's decode -/// sets the fact), and a count past `i64::MAX` reports nothing (predev's `i64::try_from` drops it). +/// RED (audit plane-decisions#4): THE DESIGN section 7, "bills one `decision` per successful, settled +/// answer from every provider ... the hosted provider keeps 1.5.5's `/usage/units` as the decision +/// count". A success with no count bills one decision; one with a whole count bills the count. #[test] -fn the_count_is_read_as_decode_response_reads_it() { - let mut reading = FarEndReading::new(); - reading.piece(br#"{"usage":{"units":3}}"#); - assert_eq!(reading.units().map(|u| u.amount), Some(3)); - for (body, amount) in [ - ( - format!(r#"{{"usage":{{"units":{}}}}}"#, i64::MAX), - Some(i64::MAX as u64), - ), - ( - format!(r#"{{"usage":{{"units":{}}}}}"#, i64::MAX as u64 + 1), - None, - ), - (format!(r#"{{"usage":{{"units":{}}}}}"#, u64::MAX), None), - ] { - let mut reading = FarEndReading::new(); - reading.piece(body.as_bytes()); - assert_eq!(reading.units().map(|u| u.amount), amount, "{body}"); +fn a_success_bills_one_decision_or_the_count_it_reports() { + assert_eq!(billed(200, br#"{}"#), Ok(vec![(DECISION, 1), (FEE, 1)])); + assert_eq!( + billed(200, br#"{"usage":{}}"#), + Ok(vec![(DECISION, 1), (FEE, 1)]) + ); + assert_eq!( + billed(201, br#"{"usage":{"units":3}}"#), + Ok(vec![(DECISION, 3), (FEE, 1)]) + ); + assert_eq!( + billed(200, br#"{"usage":{"units":3},"error":null}"#), + Ok(vec![(DECISION, 3), (FEE, 1)]), + "a null error is no error" + ); + let max = format!(r#"{{"usage":{{"units":{}}}}}"#, i64::MAX); + assert_eq!( + billed(200, max.as_bytes()), + Ok(vec![(DECISION, i64::MAX as u64), (FEE, 1)]) + ); +} + +/// RED (audit plane-decisions#4): one success rule for every count: a non-2xx answer bills nothing, +/// whatever usage it carries. +#[test] +fn a_non_success_bills_nothing() { + for status in [400, 422, 500, 503] { + assert_eq!( + billed(status, br#"{"usage":{"units":2}}"#), + Ok(vec![]), + "{status}" + ); } + let mut reading = FarEndReading::new(); + reading.piece(br#"{"usage":{"units":2}}"#); + assert_eq!( + reading.counts(None), + Ok([None; 4]), + "no status read, no bill" + ); } +/// RED (audit plane-decisions#4): a success whose usage member is there but is not a whole count +/// fails loudly, naming the member (finding 395 kept: "LOCATED, never dropped"), never a silent 0. #[test] -fn a_count_that_is_not_a_whole_number_reports_no_units() { - for body in [ - &br#"{"usage":{"units":-1}}"#[..], - br#"{"usage":{"units":1.5}}"#, - br#"{"usage":{}}"#, - br#"{}"#, +fn a_usage_member_that_is_not_a_whole_count_fails_loudly() { + for units in [ + "-1".to_string(), + "1.5".to_string(), + "7.0".to_string(), + "1e3".to_string(), + "\"7\"".to_string(), + "null".to_string(), + "{}".to_string(), + (i64::MAX as u64 + 1).to_string(), + u64::MAX.to_string(), ] { - let mut reading = FarEndReading::new(); - reading.piece(body); - assert_eq!(reading.units(), None, "{}", String::from_utf8_lossy(body)); + let body = format!(r#"{{"usage":{{"units":{units}}}}}"#); + assert_eq!( + billed(200, body.as_bytes()), + Err(Unreadable(crate::codec::PTR_USAGE_UNITS)), + "{body}" + ); } + assert_eq!( + billed(200, br#"{"usage":{"units":1,"output_tokens":-2}}"#), + Err(Unreadable(crate::codec::PTR_USAGE_OUTPUT_TOKENS)) + ); } +/// RED (audit plane-decisions#5): the token counts a success reports are billed in their classes. #[test] -fn the_units_never_read_state_or_answers() { - let mut reading = FarEndReading::new(); - reading.piece(br#"{"usage":{"units":7},"state":{"units":999},"answers":{"units":888}}"#); - assert_eq!(reading.units().map(|u| u.amount), Some(7)); +fn a_success_bills_the_tokens_it_reports() { + assert_eq!( + billed( + 200, + br#"{"usage":{"units":2,"input_tokens":12,"output_tokens":5}}"# + ), + Ok(vec![(DECISION, 2), (INPUT, 12), (OUTPUT, 5), (FEE, 1)]) + ); + assert_eq!( + billed(200, br#"{"usage":{"input_tokens":9}}"#), + Ok(vec![(DECISION, 1), (INPUT, 9), (FEE, 1)]) + ); +} + +#[test] +fn the_counts_never_read_state_or_answers() { + assert_eq!( + billed( + 200, + br#"{"usage":{"units":7},"state":{"usage":{"units":999}},"answers":{"units":888}}"# + ), + Ok(vec![(DECISION, 7), (FEE, 1)]) + ); } #[test] @@ -161,12 +255,107 @@ fn a_refusal_carries_the_kernel_text_in_the_dialect_shape() { ); } +/// RED (audit plane-decisions#1): every generation with a model mounts the claim (THE DESIGN +/// section 2: "more than one and none named is 400", so several models are served); none mounts +/// nothing, and the kernel's unclaimed-route bytes stand. +#[test] +fn one_or_more_models_mount_the_claim() { + let systemone = ("POST", ops::PATH_SYSTEMONE, tail::DIALECT_JEV); + let evaluate = ("POST", ops::PATH_EVALUATE, tail::DIALECT_NANOJEV); + assert!(served_claims(&[]).is_empty()); + assert_eq!(served_claims(&[0]), vec![systemone]); + assert_eq!(served_claims(&[0, 0]), vec![systemone]); + assert_eq!(served_claims(&[1]), vec![evaluate]); + assert_eq!(served_claims(&[1, 0, 1]), vec![systemone, evaluate]); + assert_eq!(dialect_of("nanojev"), Some(tail::DIALECT_NANOJEV)); + assert_eq!(dialect_of("anthropic"), None); +} + +fn models(names: &[&str]) -> std::collections::BTreeMap { + names.iter().map(|n| ((*n).to_string(), ())).collect() +} + +/// MODEL RESOLUTION (THE DESIGN section 2): the named model; one configured and none named is the +/// default; several and none named, or a `model` that is not a string, is 400; an unknown name 404. +#[test] +fn a_request_routes_over_the_model_it_names() { + let two = models(&["a", "b"]); + assert_eq!( + route_model(br#"{"model":"b"}"#, &two, |_| true).map(|r| r.0), + Ok("b") + ); + assert_eq!( + route_model(br#"{"model":"\u0061"}"#, &two, |_| true).map(|r| r.0), + Ok("a"), + "an escaped name is the name it spells" + ); + assert_eq!( + route_model(br#"{"state":{}}"#, &two, |_| true), + Err(Unrouted::Unnamed) + ); + assert_eq!( + route_model(br#"{"model":"zz"}"#, &two, |_| true), + Err(Unrouted::Unknown("zz".to_string())) + ); + assert_eq!( + route_model(br#"{"model":null}"#, &two, |_| true), + Err(Unrouted::NotAName) + ); + assert_eq!( + route_model(br#"{"model":["a"]}"#, &two, |_| true), + Err(Unrouted::NotAName) + ); + // A nested `model` is not the request's. + assert_eq!( + route_model(br#"{"state":{"model":"a"}}"#, &two, |_| true), + Err(Unrouted::Unnamed) + ); + + let one = models(&["jev"]); + assert_eq!( + route_model(br#"{"state":{}}"#, &one, |_| true).map(|r| r.0), + Ok("jev") + ); + assert_eq!( + route_model(b"not json", &one, |_| true).map(|r| r.0), + Ok("jev") + ); + assert_eq!( + route_model(br#"{"model":"other"}"#, &one, |_| true), + Err(Unrouted::Unknown("other".to_string())) + ); + assert_eq!( + route_model(br#"{}"#, &models(&[]), |_| true), + Err(Unrouted::Unnamed) + ); + + assert_eq!( + (Unrouted::Unnamed.status(), Unrouted::NotAName.status()), + (400, 400) + ); + assert_eq!(Unrouted::Unknown("zz".to_string()).status(), 404); +} + +/// THE `upstream_model` SPLICE (THE DESIGN section 2): the top-level value alone, when it differs. #[test] -fn only_exactly_one_model_mounts_the_claim() { - assert!(served_claims(0).is_empty()); - assert_eq!(served_claims(1), tail::CLAIMS); - assert!(served_claims(2).is_empty()); - assert!(served_claims(7).is_empty()); +fn the_splice_rewrites_the_top_level_model_value_alone() { + let body = br#"{"model" : "jev", "state":{"model":"jev"}}"#; + assert_eq!( + spliced(body, "jev-1.13.0").as_deref(), + Some(&br#"{"model" : "jev-1.13.0", "state":{"model":"jev"}}"#[..]) + ); + assert_eq!(spliced(body, "jev"), None, "the same name passes through"); + assert_eq!( + spliced(br#"{"state":{}}"#, "x"), + None, + "no model to rewrite" + ); + assert_eq!(spliced(br#"{"model":3}"#, "x"), None, "not a name"); + assert_eq!( + spliced(br#"{"model":"a"}"#, "q\"x").as_deref(), + Some(&br#"{"model":"q\"x"}"#[..]), + "the value is written as a JSON string" + ); } /// BILLABLE SUCCESS ONLY: the request fee is counted once for a far end's 2xx answer with no @@ -175,18 +364,21 @@ fn only_exactly_one_model_mounts_the_claim() { fn the_request_fee_is_counted_on_a_success_alone() { let mut ok = FarEndReading::new(); ok.piece(br#"{"usage":{"units":3}}"#); - let fee = ok.fee(Some(200)).expect("a success owes its fee"); + let fee = ok.counts(Some(200)).expect("a success")[3].expect("a success owes its fee"); + assert_eq!((fee.class, fee.amount, fee.reported), (FEE, 1, true)); + // RED ARMS. assert_eq!( - (fee.class, fee.amount, fee.reported), - (tail::CLASS_FEE_INDEX, 1, true) + ok.counts(Some(422)), + Ok([None; 4]), + "a refused request owes none" + ); + assert_eq!(ok.counts(Some(500)), Ok([None; 4])); + assert_eq!(ok.counts(None), Ok([None; 4]), "no status read, no fee"); + assert_eq!( + billed(200, br#"{"error":{"code":"x","message":"y"}}"#), + Ok(vec![]), + "an error answer owes none" ); - // RED ARMS. - assert_eq!(ok.fee(Some(422)), None, "a refused request owes none"); - assert_eq!(ok.fee(Some(500)), None); - assert_eq!(ok.fee(None), None, "no status read, no fee"); - let mut failed = FarEndReading::new(); - failed.piece(br#"{"error":{"code":"x","message":"y"}}"#); - assert_eq!(failed.fee(Some(200)), None, "an error answer owes none"); } /// THE CALLER'S FIELDS REACH THE FAR END (DEC-SERVE Q2, DIALECT-FIDELITY F2): every one, in order, @@ -203,8 +395,11 @@ fn every_caller_field_but_the_governed_set_is_relayed() { (b"Proxy-Authorization", b"Basic x"), (b"accept-language", b"en"), ]; + let kept = relayed_fields(caller); assert_eq!( - relayed_fields(caller), + kept.iter() + .map(|(n, v)| (n.to_vec(), v.to_vec())) + .collect::>(), vec![ (b"x-trace".to_vec(), b"a".to_vec()), (b"x-trace".to_vec(), b"b".to_vec()), @@ -213,3 +408,176 @@ fn every_caller_field_but_the_governed_set_is_relayed() { ); assert!(relayed_fields(std::iter::empty()).is_empty()); } + +/// The money rule's reading of a whole answer by its declared pointers, as the body would be read +/// kept whole: the reference the piece-by-piece reading is held to. +fn by_pointer(status: u32, body: &[u8]) -> Result, Unreadable> { + use crate::codec::{read_raw, PTR_ERROR}; + let ok = (200..300).contains(&status) && read_raw(body, PTR_ERROR).is_none_or(|e| e == b"null"); + if !ok { + return Ok(vec![]); + } + let count = |p: &'static str| match read_raw(body, p) { + None => Ok(None), + Some(raw) => whole(raw).map(Some).ok_or(Unreadable(p)), + }; + let mut out = vec![(DECISION, count(crate::codec::PTR_USAGE_UNITS)?.unwrap_or(1))]; + if let Some(n) = count(crate::codec::PTR_USAGE_INPUT_TOKENS)? { + out.push((INPUT, n)); + } + if let Some(n) = count(crate::codec::PTR_USAGE_OUTPUT_TOKENS)? { + out.push((OUTPUT, n)); + } + out.push((FEE, 1)); + Ok(out) +} + +/// The answer read in two pieces split at `at`. +fn split_billed(status: u32, body: &[u8], at: usize) -> Result, Unreadable> { + let mut reading = FarEndReading::new(); + reading.piece(&body[..at]); + reading.piece(&body[at..]); + reading.counts(Some(status)).map(|c| { + c.into_iter() + .flatten() + .map(|u| (u.class, u.amount)) + .collect() + }) +} + +/// RED (audit plane-decisions#8): the answer is read as it passes, in fixed space, wherever the +/// pieces split it, and reads exactly what the declared pointers read over the whole answer. +#[test] +fn the_reading_is_the_pointers_reading_wherever_the_pieces_split() { + let answers: [&[u8]; 14] = [ + br#"{"request_id":"req_1","usage":{"units":42},"answers":{"decision":"approve"}}"#, + br#" { "usage" : { "input_tokens" : 12 , "units" : 3, "output_tokens":5 } } "#, + br#"{"answers":{"usage":{"units":9}},"usage":{"units":2}}"#, + br#"{"state":{"error":"x","usage":{"units":"q"}},"usage":{}}"#, + br#"{"error":null,"usage":{"units":7}}"#, + br#"{"error":{"code":"x","message":"y"},"usage":{"units":3}}"#, + br#"{"error":"down"}"#, + br#"{"usage":{"units":7.5}}"#, + br#"{"usage":{"units":"7"}}"#, + br#"{"usage":{"units":[1]}}"#, + br#"{"usage":{"units":99999999999999999999999999}}"#, + br#"{"a\"usage":{"units":"no"},"usage":{"output_tokens":-1}}"#, + br#"{"usage":"n/a","x":[{"usage":{"units":1}}]}"#, + br#"[{"usage":{"units":5}}]"#, + ]; + for body in answers { + for status in [200, 500] { + let want = by_pointer(status, body); + for at in 0..=body.len() { + assert_eq!( + split_billed(status, body, at), + want, + "{status} {} split at {at}", + String::from_utf8_lossy(body) + ); + } + } + } +} + +/// An answer of any size is read in the reading's own fixed space. +#[test] +fn a_large_answer_is_read_without_keeping_it() { + let mut reading = FarEndReading::new(); + let before = std::mem::size_of_val(&reading); + reading.piece(br#"{"answers":{"blob":""#); + let filler = [b'x'; 4096]; + for _ in 0..256 { + reading.piece(&filler); + } + reading.piece(br#""},"usage":{"units":4}}"#); + assert_eq!(std::mem::size_of_val(&reading), before); + assert_eq!( + reading.counts(Some(200)).map(|c| c[0].map(|u| u.amount)), + Ok(Some(4)) + ); +} + +/// The counts of a `nanojev` answer, `(class, amount)`. +fn nanojev_billed(status: u32, body: &[u8]) -> Result, Unreadable> { + let mut reading = FarEndReading::for_dialect(tail::DIALECT_NANOJEV); + reading.piece(body); + reading.counts(Some(status)).map(|c| { + c.into_iter() + .flatten() + .map(|u| (u.class, u.amount)) + .collect() + }) +} + +/// RED (audit plane-decisions#3): THE DESIGN section 2, NanoJev "meters the upstream's own billing +/// unit, counted from the answer already read (`execution.states`; an answer with no count refuses +/// loudly". Its answer is read by its own pointer, never jev's. +#[test] +fn a_nanojev_answer_bills_its_execution_states() { + assert_eq!( + nanojev_billed(200, br#"{"execution":{"states":3},"result":{}}"#), + Ok(vec![(DECISION, 3), (FEE, 1)]) + ); + assert_eq!( + nanojev_billed( + 200, + br#"{"usage":{"units":9,"input_tokens":4},"execution":{"states":1}}"# + ), + Ok(vec![(DECISION, 1), (FEE, 1)]), + "jev's usage members are not nanojev's" + ); + for missing in [ + &br#"{"result":{}}"#[..], + br#"{"execution":{}}"#, + br#"{"usage":{"units":2}}"#, + ] { + assert_eq!( + nanojev_billed(200, missing), + Err(Unreadable(crate::codec::PTR_EXECUTION_STATES)), + "{}", + String::from_utf8_lossy(missing) + ); + } + assert_eq!( + nanojev_billed(200, br#"{"execution":{"states":2.5}}"#), + Err(Unreadable(crate::codec::PTR_EXECUTION_STATES)) + ); + assert_eq!( + nanojev_billed(500, br#"{"result":{}}"#), + Ok(vec![]), + "a failure bills nothing" + ); + // Wherever the pieces split it. + let body = br#"{"execution" : {"trace":[{"states":99}], "states" : 12}}"#; + for at in 0..=body.len() { + let mut reading = FarEndReading::for_dialect(tail::DIALECT_NANOJEV); + reading.piece(&body[..at]); + reading.piece(&body[at..]); + assert_eq!( + reading.counts(Some(200)).map(|c| c[0].map(|u| u.amount)), + Ok(Some(12)), + "split at {at}" + ); + } +} + +/// RED (audit plane-decisions#3): a request routes among its claim's dialect's models; a model of +/// the other dialect is this dialect's unknown model, and one model of the dialect is its default. +#[test] +fn a_model_of_the_other_dialect_is_this_dialects_unknown_model() { + let models: std::collections::BTreeMap = + [("j".to_string(), 0), ("n".to_string(), 1)].into(); + let jev = |d: &u32| *d == tail::DIALECT_JEV; + let nano = |d: &u32| *d == tail::DIALECT_NANOJEV; + assert_eq!( + route_model(br#"{"model":"n"}"#, &models, jev), + Err(Unrouted::Unknown("n".to_string())) + ); + assert_eq!( + route_model(br#"{"model":"n"}"#, &models, nano).map(|r| r.0), + Ok("n") + ); + assert_eq!(route_model(br#"{}"#, &models, jev).map(|r| r.0), Ok("j")); + assert_eq!(route_model(br#"{}"#, &models, nano).map(|r| r.0), Ok("n")); +} diff --git a/crates/busbar-plane-decisions/src/tests/ops.rs b/crates/busbar-plane-decisions/src/tests/ops.rs index dc9d8e2f06..d99737900a 100644 --- a/crates/busbar-plane-decisions/src/tests/ops.rs +++ b/crates/busbar-plane-decisions/src/tests/ops.rs @@ -38,3 +38,10 @@ fn op_classes_matches_the_method_table() { assert!(OP_CLASSES.contains(&row.op)); } } + +#[test] +fn row_for_matches_evaluate() { + let row = row_for("POST", PATH_EVALUATE).expect("evaluate is declared"); + assert_eq!(row.op, OP_EVALUATE); + assert!(row.has_request_body); +} diff --git a/crates/busbar-plane-decisions/src/tests/plane_door.rs b/crates/busbar-plane-decisions/src/tests/plane_door.rs index 4fdabafd06..d8a77eb70d 100644 --- a/crates/busbar-plane-decisions/src/tests/plane_door.rs +++ b/crates/busbar-plane-decisions/src/tests/plane_door.rs @@ -1,6 +1,61 @@ use super::*; use crate::ops; +/// `section`'s routes with no provider facts stated. +fn routed(section: &DecisionsSection) -> BTreeMap { + routes(section, &BTreeMap::new()).expect("every model's dialect is spoken") +} + +/// RED (audit plane-decisions#3): each dialect's claim is published when a model of it is +/// configured, and wears its dialect for a refusal before `arrive`. +#[test] +fn each_dialects_claim_is_published_with_a_model_of_it() { + let jev = ClaimSpec::new("POST", ops::PATH_SYSTEMONE, claims::TRANSPORT, CLAIM_EXACT); + let evaluate = ClaimSpec { + refusal_dialect: 1, + ..ClaimSpec::new("POST", ops::PATH_EVALUATE, claims::TRANSPORT, CLAIM_EXACT) + }; + assert_eq!( + snapshot_spec(&[tail::DIALECT_NANOJEV]).claims, + vec![evaluate.clone()] + ); + assert_eq!( + snapshot_spec(&[tail::DIALECT_NANOJEV, tail::DIALECT_JEV]).claims, + vec![jev.clone(), evaluate] + ); + assert_eq!(snapshot_spec(&[tail::DIALECT_JEV]).claims, vec![jev]); +} + +/// RED (audit plane-decisions#3): THE DESIGN section 2, "a model's dialect is its override or its +/// provider's protocol"; a dialect the plane does not speak fails closed, naming those it does. +#[test] +fn a_models_dialect_is_its_override_or_its_providers_protocol() { + let section = read_settings( + br#"{"models":{"o":{"provider":"p","protocol":"nanojev"},"f":{"provider":"nano"},"j":{"provider":"p"}}}"#, + ) + .expect("the section reads"); + let facts = BTreeMap::from([ + ("nano".to_string(), "nanojev".to_string()), + ("p".to_string(), "jev".to_string()), + ]); + let r = routes(§ion, &facts).expect("every dialect is spoken"); + assert_eq!( + (r["o"].dialect, r["f"].dialect, r["j"].dialect), + ( + tail::DIALECT_NANOJEV, + tail::DIALECT_NANOJEV, + tail::DIALECT_JEV + ) + ); + let bogus = read_settings(br#"{"models":{"m":{"provider":"p","protocol":"bogus"}}}"#) + .expect("the section reads"); + let words = routes(&bogus, &facts).expect_err("an unspoken dialect fails closed"); + assert!( + words.contains("bogus") && words.contains("jev, nanojev"), + "{words}" + ); +} + #[test] fn the_statement_names_the_crate_version() { let manifest = include_str!("../../Cargo.toml"); @@ -30,8 +85,8 @@ fn a_member_the_section_does_not_declare_is_refused_in_the_grammars_words() { } #[test] -fn exactly_one_model_claims_systemone_and_nothing_else_claims_anything() { - let one = snapshot_spec(1); +fn one_or_more_models_claim_systemone_and_none_claims_nothing() { + let one = snapshot_spec(&[tail::DIALECT_JEV]); assert_eq!( one.claims, vec![ClaimSpec::new( @@ -44,8 +99,13 @@ fn exactly_one_model_claims_systemone_and_nothing_else_claims_anything() { assert!(one.admin_routes.is_empty()); // Served on the plain data plane to a keyed caller: no audience, no protected-resource document. assert!(one.audience.is_none() && one.resource_metadata.is_none()); - for models in [0, 2, 3] { - assert!(snapshot_spec(models).claims.is_empty(), "{models} models"); + assert!( + snapshot_spec(&[]).claims.is_empty(), + "no model claims nothing" + ); + for models in [2, 3] { + let jev = vec![tail::DIALECT_JEV; models]; + assert_eq!(snapshot_spec(&jev).claims, one.claims, "{models} models"); } } @@ -54,18 +114,18 @@ fn a_generation_lists_every_configured_model_in_name_order() { let two = read_settings(br#"{"models":{"b":{"provider":"typesafe"},"a":{"provider":"typesafe"}}}"#) .expect("the section reads"); - let spec = generation_spec(&two); + let spec = generation_spec(&routed(&two)); assert_eq!(spec.listed, vec!["a".to_string(), "b".to_string()]); - assert_eq!(spec.claims, snapshot_spec(2).claims); + assert_eq!(spec.claims, snapshot_spec(&[0, 0]).claims); let one = read_settings(br#"{"models":{"jev":{"provider":"typesafe"}}}"#).expect("the section reads"); - let spec = generation_spec(&one); + let spec = generation_spec(&routed(&one)); assert_eq!(spec.listed, vec!["jev".to_string()]); - assert_eq!(spec.claims, snapshot_spec(1).claims); + assert_eq!(spec.claims, snapshot_spec(&[0]).claims); let empty = read_settings(b"").expect("an empty blob reads"); - assert!(generation_spec(&empty).listed.is_empty()); + assert!(generation_spec(&routed(&empty)).listed.is_empty()); } #[test] diff --git a/crates/busbar-plane-decisions/tests/byte_fixtures.rs b/crates/busbar-plane-decisions/tests/byte_fixtures.rs index 03f2bd3e9f..027aa97610 100644 --- a/crates/busbar-plane-decisions/tests/byte_fixtures.rs +++ b/crates/busbar-plane-decisions/tests/byte_fixtures.rs @@ -31,8 +31,9 @@ const SYSTEMONE_422: &[u8] = br#"{"error":{"code":"invalid_state","message":"bad const MODELS_LIST: &[u8] = br#"{"request_id":"req_2","data":[{"id":"jev-1.13.0","object":"model"}]}"#; -/// The request body a `systemone` call carries — this plane never reads a pointer off it (see -/// `codec::REQUEST_PTRS`), so its shape only matters for byte-identity forwarding. +/// The request body a `systemone` call carries — this plane reads it at its top-level `model` alone +/// (see `codec::REQUEST_PTRS`), and names none here, so its shape only matters for byte-identity +/// forwarding. const SYSTEMONE_REQUEST: &[u8] = br#"{"state":{"session":"caller-secret-state-xyz"},"context":{}}"#; #[test] diff --git a/crates/busbar-plane-decisions/tests/conformance.json b/crates/busbar-plane-decisions/tests/conformance.json index 3d4dedf437..bf72d3c51a 100644 --- a/crates/busbar-plane-decisions/tests/conformance.json +++ b/crates/busbar-plane-decisions/tests/conformance.json @@ -6,7 +6,7 @@ "models": { "a": { "provider": "typesafe" }, "b": { "provider": "typesafe" } } }, "open_claims": [["POST", "/v1/systemone"]], - "refresh_claims": [], + "refresh_claims": [["POST", "/v1/systemone"]], "member": "jev", "claimed": { "unit": 7, "method": "POST", "target": "/v1/systemone" }, "unclaimed": { "unit": 8, "method": "GET", "target": "/v1/models", "status": 404 }, diff --git a/crates/busbar-plane-decisions/tests/door.rs b/crates/busbar-plane-decisions/tests/door.rs new file mode 100644 index 0000000000..f38bfd2135 --- /dev/null +++ b/crates/busbar-plane-decisions/tests/door.rs @@ -0,0 +1,367 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (C) 2026 Busbar Inc and contributors + +//! THE DOOR'S ANSWERS, DRIVEN OVER ITS ABI as the kernel's plane driver drives them. +//! +//! * MODEL RESOLUTION (THE DESIGN section 2, the decisions bullet; DECISIONS D8b): "a request routes +//! by its model; one configured model and none named is the default (byte-identical); more than +//! one and none named is 400; an unknown model is 404." The claim is published whenever a model is +//! configured. +//! * `upstream_model` (the same bullet): "rewrites only the top-level model value, by span splice, +//! when it is set and differs; otherwise the body passes through." + +#![allow(unsafe_code)] + +mod door_harness; + +use busbar_contract::abi::mechanism::call::Outcome; +use busbar_contract::abi::plane::ROUTE_DIRECT; +use door_harness::{open, Bufs, Given}; + +const SYSTEMONE: (&str, &str) = ("POST", "/v1/systemone"); + +const TWO: &str = r#"{"models":{"a":{"provider":"typesafe"},"b":{"provider":"typesafe"}}}"#; +const ONE: &str = r#"{"models":{"jev":{"provider":"typesafe"}}}"#; + +fn claims(c: &[(&str, &str)]) -> Vec<(String, String)> { + c.iter() + .map(|(v, t)| ((*v).to_string(), (*t).to_string())) + .collect() +} + +/// RED (audit plane-decisions#1): several configured models publish the claim; none publishes none. +#[test] +fn every_generation_with_a_model_claims_systemone() { + assert_eq!(open(TWO).claims, claims(&[SYSTEMONE]), "two models"); + assert_eq!(open(ONE).claims, claims(&[SYSTEMONE]), "one model"); + let none = open(r#"{"models":{}}"#); + assert!(none.claims.is_empty(), "no model claims nothing"); + // A refresh onto several models keeps the claim, and onto none drops it. + assert_eq!(none.refresh(TWO, 2), claims(&[SYSTEMONE])); + assert!(none.refresh("{}", 3).is_empty()); +} + +/// RED (audit plane-decisions#1): a request routes over the model it names; several configured and +/// none named is 400; a name no model has is 404; one configured and none named is the default. +#[test] +fn a_request_routes_by_its_model() { + let two = open(TWO); + let b = two.arrive(1, br#"{"model":"b","state":{}}"#); + assert_eq!( + (b.outcome, b.route, b.entry.as_str()), + (Outcome::Ready, ROUTE_DIRECT, "b"), + "{b:?}" + ); + let a = two.arrive(2, br#"{"state":{},"model":"a"}"#); + assert_eq!( + (a.outcome, a.entry.as_str()), + (Outcome::Ready, "a"), + "{a:?}" + ); + + let unnamed = two.arrive(3, br#"{"state":{}}"#); + assert_eq!( + (unnamed.outcome, unnamed.status), + (Outcome::Refused, 400), + "{unnamed:?}" + ); + assert!( + unnamed.refusal != 0 && !unnamed.text.is_empty(), + "{unnamed:?}" + ); + + let unknown = two.arrive(4, br#"{"model":"zz","state":{}}"#); + assert_eq!( + (unknown.outcome, unknown.status), + (Outcome::Refused, 404), + "{unknown:?}" + ); + assert!(unknown.text.contains("'zz'"), "{unknown:?}"); + + let not_a_name = two.arrive(5, br#"{"model":7}"#); + assert_eq!( + (not_a_name.outcome, not_a_name.status), + (Outcome::Refused, 400), + "{not_a_name:?}" + ); + + let one = open(ONE); + let default = one.arrive(6, br#"{"state":{}}"#); + assert_eq!( + (default.outcome, default.route, default.entry.as_str()), + (Outcome::Ready, ROUTE_DIRECT, "jev"), + "{default:?}" + ); + let named = one.arrive(7, br#"{"model":"jev"}"#); + assert_eq!( + (named.outcome, named.entry.as_str()), + (Outcome::Ready, "jev") + ); + let elsewhere = one.arrive(8, br#"{"model":"other"}"#); + assert_eq!( + (elsewhere.outcome, elsewhere.status), + (Outcome::Refused, 404), + "{elsewhere:?}" + ); +} + +/// A refused arrival is answered in jev's error shape, the arrival's words and status. +#[test] +fn an_unrouted_arrival_renders_in_the_dialect_shape() { + let two = open(TWO); + let unknown = two.arrive(1, br#"{"model":"zz"}"#); + let (outcome, body) = two.refusal(1, unknown.status, &unknown.text, true); + assert_eq!(outcome, Outcome::Ready); + let v: serde_json::Value = serde_json::from_slice(&body).expect("JSON"); + assert_eq!(v["error"]["message"], unknown.text.as_str(), "{v}"); + assert!(v["error"]["code"].is_string(), "{v}"); +} + +/// The caller's body as the far end receives it, for a unit routed over `settings` by `body`. +fn sent(settings: &str, body: &[u8]) -> String { + let door = open(settings); + let arrived = door.arrive(1, body); + assert_eq!(arrived.outcome, Outcome::Ready, "{arrived:?}"); + let mut bufs = Bufs::new(1 << 16); + let attempt = door.piece(1, Given::attempt(1), &mut bufs); + assert_eq!(attempt.request, "POST /v1/systemone"); + let caller = door.piece(1, Given::caller(body), &mut bufs); + assert!(caller.far, "the caller's body goes to the far end"); + String::from_utf8(caller.bytes).expect("UTF-8") +} + +/// RED (audit plane-decisions#2): a set `upstream_model` that differs replaces the top-level model +/// value alone; every other byte (member order, whitespace) is the caller's. +#[test] +fn upstream_model_is_spliced_into_the_top_level_model() { + let upstream = r#"{"models":{"jev":{"provider":"typesafe","upstream_model":"jev-1.13.0"}}}"#; + let body = "{ \"state\" : {\"model\":\"inner\"},\n \"model\" : \"jev\" , \"n\":[1, 2] }"; + assert_eq!( + sent(upstream, body.as_bytes()), + "{ \"state\" : {\"model\":\"inner\"},\n \"model\" : \"jev-1.13.0\" , \"n\":[1, 2] }" + ); + // The same name, or none set, or a body that names no model: the body passes through. + let same = r#"{"models":{"jev":{"provider":"typesafe","upstream_model":"jev"}}}"#; + assert_eq!(sent(same, body.as_bytes()), body); + assert_eq!(sent(ONE, body.as_bytes()), body); + let unnamed = "{\"state\":{}}"; + assert_eq!(sent(upstream, unnamed.as_bytes()), unnamed); +} + +/// One unit served over [`ONE`]: its ATTEMPT, the caller's body, and the far end's whole answer, +/// `status` with `answer`. Answers the far end's piece. +fn answered(status: u32, answer: &[u8]) -> door_harness::Answered { + let door = open(ONE); + assert_eq!(door.arrive(1, br#"{"state":{}}"#).outcome, Outcome::Ready); + let mut bufs = Bufs::new(1 << 16); + door.piece(1, Given::attempt(1), &mut bufs); + door.piece(1, Given::caller(br#"{"state":{}}"#), &mut bufs); + door.piece(1, Given::whole(status, answer), &mut bufs) +} + +/// The class index the tail names `name` at. +fn class(name: &str) -> u32 { + let tail = busbar_plane_decisions::plane_door::TAIL; + // SAFETY: the tail's billable classes are a `'static` list of `billable_classes_len` entries, + // each naming `'static` strings. + let classes = + unsafe { std::slice::from_raw_parts(tail.billable_classes, tail.billable_classes_len) }; + let named = |c: &busbar_contract::abi::plane::BillableClass| { + // SAFETY: as above. + unsafe { std::slice::from_raw_parts(c.class.ptr, c.class.len) == name.as_bytes() } + }; + let at = classes + .iter() + .position(named) + .unwrap_or_else(|| panic!("the tail declares no class `{name}`")); + u32::try_from(at).unwrap() +} + +/// `(class, amount)` of every count an answer reported, each REPORTED. +fn counts(a: &door_harness::Answered) -> Vec<(u32, u64)> { + assert!( + a.units + .iter() + .all(|(_, source, _)| *source == busbar_contract::abi::plane::UNITS_REPORTED), + "{a:?}" + ); + a.units.iter().map(|(c, _, n)| (*c, *n)).collect() +} + +/// RED (audit plane-decisions#4): THE DESIGN section 7, "bills one `decision` per successful, settled +/// answer from every provider": a success with no count bills one decision, beside its fee. +#[test] +fn a_success_with_no_count_bills_one_decision() { + let fee = class(busbar_contract::plane::PER_REQUEST); + let a = answered(200, br#"{"answers":{}}"#); + assert_eq!(a.outcome, Some(Outcome::Ready), "{a:?}"); + assert_eq!(counts(&a), vec![(class("decision"), 1), (fee, 1)]); + let reported = answered(200, br#"{"usage":{"units":42}}"#); + assert_eq!(counts(&reported), vec![(class("decision"), 42), (fee, 1)]); +} + +/// RED (audit plane-decisions#4): one success rule: a non-2xx answer bills nothing, whatever count +/// it carries. +#[test] +fn a_non_success_bills_nothing() { + let a = answered(500, br#"{"usage":{"units":2}}"#); + assert_eq!(a.outcome, Some(Outcome::Ready), "{a:?}"); + assert!(a.units.is_empty(), "{a:?}"); + assert_eq!(a.bytes, br#"{"usage":{"units":2}}"#, "relayed unchanged"); +} + +/// RED (audit plane-decisions#4): a success whose count is not a whole count fails the unit loudly +/// (finding 395, kept: "LOCATED, never dropped"), never a silent zero. +#[test] +fn a_success_with_an_unreadable_count_fails_loudly() { + for answer in [ + &br#"{"usage":{"units":7.5}}"#[..], + br#"{"usage":{"units":"7"}}"#, + br#"{"usage":{"units":-3}}"#, + ] { + let a = answered(200, answer); + assert_eq!( + (a.outcome, a.short), + (Some(Outcome::Failed), false), + "{}: {a:?}", + String::from_utf8_lossy(answer) + ); + assert!(a.units.is_empty() && a.bytes.is_empty(), "{a:?}"); + } +} + +/// RED (audit plane-decisions#5): THE DESIGN section 7, "its card prices `decision`, `input_tokens` +/// and `output_tokens` (family `decision`, all three required, 0 = free)": the tail declares the +/// three in family `decision`, and a success's reported tokens are billed in them. +#[test] +fn the_tokens_a_success_reports_are_billed_in_family_decision() { + let tail = busbar_plane_decisions::plane_door::TAIL; + // SAFETY: as in `class`. + let classes = + unsafe { std::slice::from_raw_parts(tail.billable_classes, tail.billable_classes_len) }; + let family = |name: &str| { + let c = &classes[class(name) as usize]; + // SAFETY: as in `class`. + String::from_utf8_lossy(unsafe { std::slice::from_raw_parts(c.family.ptr, c.family.len) }) + .into_owned() + }; + for name in ["decision", "input_tokens", "output_tokens"] { + assert_eq!(family(name), "decision", "{name}"); + } + let a = answered( + 200, + br#"{"usage":{"units":2,"input_tokens":12,"output_tokens":5}}"#, + ); + let got = counts(&a); + for want in [ + (class("decision"), 2), + (class("input_tokens"), 12), + (class("output_tokens"), 5), + ] { + assert!(got.contains(&want), "{want:?} in {got:?}"); + } +} + +const MAX_UNITS: u64 = busbar_plane_decisions::plane_door::MAX_UNITS as u64; + +/// A live unit over [`ONE`]: arrived, its ATTEMPT crossed. +fn live(door: &door_harness::Door, unit: u64, bufs: &mut Bufs) { + let arrived = door.arrive(unit, br#"{"state":{}}"#); + assert_eq!(arrived.outcome, Outcome::Ready, "unit {unit}: {arrived:?}"); + let attempt = door.piece(unit, Given::attempt(1), bufs); + assert_eq!(attempt.request, "POST /v1/systemone", "unit {unit}"); +} + +/// Whether `unit`'s body still reaches the far end: its ATTEMPT's state is still held. +fn still_live(door: &door_harness::Door, unit: u64, bufs: &mut Bufs) -> bool { + door.piece(unit, Given::caller(br#"{"state":{}}"#), bufs) + .far +} + +/// RED (audit plane-decisions#6): a refusal ends its unit and drops its state, so refused traffic +/// never crowds out a live unit. +#[test] +fn a_refused_unit_is_dropped_and_a_live_unit_kept() { + let door = open(ONE); + let mut bufs = Bufs::new(1 << 16); + live(&door, 1, &mut bufs); + for unit in 2..2 + MAX_UNITS { + assert_eq!( + door.arrive(unit, br#"{}"#).outcome, + Outcome::Ready, + "unit {unit}" + ); + door.refusal(unit, 429, "the key's limit is spent", false); + } + assert!(still_live(&door, 1, &mut bufs), "the live unit was dropped"); +} + +/// RED (audit plane-decisions#6): a cancel ends the unit on its ticket and drops its state. +#[test] +fn a_cancelled_unit_is_dropped_and_a_live_unit_kept() { + let door = open(ONE); + let mut bufs = Bufs::new(1 << 16); + live(&door, 1, &mut bufs); + for unit in 2..2 + MAX_UNITS { + live(&door, unit, &mut bufs); + let (outcome, disposition) = door.cancel(door_harness::ticket_of(unit)); + assert_eq!( + (outcome, disposition), + (Outcome::Ready, busbar_contract::abi::plane::CANCEL_ABORTED), + "unit {unit}" + ); + } + assert!(still_live(&door, 1, &mut bufs), "the live unit was dropped"); +} + +/// RED (audit plane-decisions#6): a unit cut after its far end answered was not aborted before it. +#[test] +fn a_unit_cancelled_after_its_far_end_answered_is_cut_partway() { + let door = open(ONE); + let mut bufs = Bufs::new(1 << 16); + live(&door, 1, &mut bufs); + assert!(still_live(&door, 1, &mut bufs)); + let first = door.piece(1, Given::far(Some(200), false, br#"{"usage":"#), &mut bufs); + assert_eq!(first.outcome, Some(Outcome::Ready)); + assert_eq!( + door.cancel(door_harness::ticket_of(1)), + ( + Outcome::Ready, + busbar_contract::abi::plane::CANCEL_OK_PARTIAL + ) + ); +} + +/// RED (audit plane-decisions#6): past [`MAX_UNITS`] live units a new arrival is refused, about +/// the entry it routed over, and no live unit is dropped to make room. +#[test] +fn past_the_cap_an_arrival_is_refused_and_no_live_unit_dropped() { + let door = open(ONE); + let mut bufs = Bufs::new(1 << 16); + for unit in 1..=MAX_UNITS { + live(&door, unit, &mut bufs); + } + let over = door.arrive(MAX_UNITS + 1, br#"{"state":{}}"#); + assert_eq!( + (over.outcome, over.status, over.route, over.entry.as_str()), + (Outcome::Refused, 503, ROUTE_DIRECT, "jev"), + "{over:?}" + ); + assert!( + still_live(&door, 1, &mut bufs), + "the first live unit was dropped" + ); + assert!(still_live(&door, MAX_UNITS, &mut bufs)); +} + +/// A unit that arrived and never crossed a piece while [`MAX_UNITS`] arrivals followed is +/// reclaimed, so the instance never refuses every arrival for good. +#[test] +fn an_arrival_never_started_is_reclaimed_once_the_cap_is_reached() { + let door = open(ONE); + for unit in 1..=MAX_UNITS { + assert_eq!(door.arrive(unit, br#"{}"#).outcome, Outcome::Ready); + } + let next = door.arrive(MAX_UNITS + 1, br#"{}"#); + assert_eq!(next.outcome, Outcome::Ready, "{next:?}"); +} diff --git a/crates/busbar-plane-decisions/tests/door_alloc.rs b/crates/busbar-plane-decisions/tests/door_alloc.rs new file mode 100644 index 0000000000..4570d98400 --- /dev/null +++ b/crates/busbar-plane-decisions/tests/door_alloc.rs @@ -0,0 +1,88 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (C) 2026 Busbar Inc and contributors + +//! WHAT RELAYING A FAR-END ANSWER ALLOCATES (THE DESIGN section 11.2: "no allocation and no +//! blocking on the hot path"): a piece of the far end's answer is read as it passes and written +//! straight into the host's reply buffer, so relaying piece after piece of an answer allocates +//! nothing, however long the answer runs. + +#![allow(unsafe_code)] + +mod door_harness; + +use std::alloc::{GlobalAlloc, Layout, System}; +use std::cell::Cell; + +use busbar_contract::abi::mechanism::call::Outcome; +use door_harness::{open, Bufs, Given}; + +thread_local! { + static ALLOCS: Cell = const { Cell::new(0) }; +} + +struct Counting; + +// SAFETY: every call is forwarded verbatim to the system allocator; the counter is a thread-local +// `Cell` of a plain integer, touched only on the allocating thread, and never reads or writes the +// memory being handed out. +unsafe impl GlobalAlloc for Counting { + unsafe fn alloc(&self, layout: Layout) -> *mut u8 { + let _ = ALLOCS.try_with(|c| c.set(c.get() + 1)); + // SAFETY: forwarded as given. + unsafe { System.alloc(layout) } + } + unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) { + // SAFETY: forwarded as given. + unsafe { System.dealloc(ptr, layout) } + } + unsafe fn realloc(&self, ptr: *mut u8, layout: Layout, new_size: usize) -> *mut u8 { + let _ = ALLOCS.try_with(|c| c.set(c.get() + 1)); + // SAFETY: forwarded as given. + unsafe { System.realloc(ptr, layout, new_size) } + } +} + +#[global_allocator] +static ALLOC: Counting = Counting; + +fn allocations_of(f: impl FnOnce()) -> u64 { + let before = ALLOCS.with(Cell::get); + f(); + ALLOCS.with(Cell::get) - before +} + +/// RED (audit plane-decisions#8): 256 pieces of a 1 MiB answer, each 4 KiB, relayed through the +/// door: no allocation (predev kept the whole answer, growing a buffer, and copied each piece +/// again before writing it). +#[test] +fn relaying_an_answer_piece_by_piece_allocates_nothing() { + let door = open(r#"{"models":{"jev":{"provider":"typesafe"}}}"#); + let mut bufs = Bufs::new(1 << 16); + assert_eq!(door.arrive(1, br#"{"state":{}}"#).outcome, Outcome::Ready); + door.piece(1, Given::attempt(1), &mut bufs); + door.piece(1, Given::caller(br#"{"state":{}}"#), &mut bufs); + let head = door.piece( + 1, + Given::far(Some(200), false, br#"{"answers":{"blob":""#), + &mut bufs, + ); + assert_eq!(head.outcome, Some(Outcome::Ready)); + let filler = [b'x'; 4096]; + let piece = Given::far(None, false, &filler); + // Warm: one piece outside the window. + door.piece(1, piece, &mut bufs); + let mut relayed = 0; + let count = allocations_of(|| { + for _ in 0..256 { + relayed += door.piece_bytes(1, piece, &mut bufs); + } + }); + assert_eq!(relayed, 256 * filler.len(), "every byte is relayed"); + assert_eq!(count, 0, "relaying 256 pieces allocated {count} times"); + let last = door.piece( + 1, + Given::far(None, true, br#""},"usage":{"units":4}}"#), + &mut bufs, + ); + assert_eq!(last.units.first().map(|u| u.2), Some(4), "{last:?}"); +} diff --git a/crates/busbar-plane-decisions/tests/door_harness/mod.rs b/crates/busbar-plane-decisions/tests/door_harness/mod.rs new file mode 100644 index 0000000000..a99d20c939 --- /dev/null +++ b/crates/busbar-plane-decisions/tests/door_harness/mod.rs @@ -0,0 +1,545 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (C) 2026 Busbar Inc and contributors + +//! THE DOOR, DRIVEN OVER ITS OWN ABI as the kernel's plane driver drives it: `open`, `refresh`, +//! `arrive`, `on_piece`, `refusal` and `cancel`, each a frame of plain C data crossing the door's +//! table. The decisions door calls no host service, so the host tables it is opened with are empty. + +#![allow(dead_code)] + +use std::os::raw::c_void; + +use busbar_contract::abi::mechanism::call::{ + AbiStr, Blob, Field, InHead, OutHead, Outcome, Span, BLOB_OCTETS, +}; +use busbar_contract::abi::mechanism::lifecycle::{ + slot as life, CancelIn, CancelOut, OpenIn, OpenOut, OpsHead, RefreshIn, ValidateIn, +}; +use busbar_contract::abi::mechanism::ticket::{HostCtx, HostTables, Ticket}; +use busbar_contract::abi::plane::{ + slot, ArriveIn, ArriveOut, OnPieceIn, OnPieceOut, Ops, OutField, PlaneCancelIn, PlaneCancelOut, + PlaneOpenIn, PlaneOpenOut, PlaneRefreshOut, PlaneSnapshot, ProviderFacts, RecordWrite, + RefusalIn, RefusalOut, UnitCount, EMIT_DONE, EMIT_TO_FAR_END, FROM_CALLER, FROM_FAR_END, + FROM_KERNEL, PIECE_HAS_STATUS, PIECE_LAST, REFUSAL_ARRIVE, REFUSAL_KERNEL, +}; +use busbar_plane_decisions::plane_door::door; + +pub fn z() -> T { + // SAFETY: every `in`/`out` here is plain C data; all-zero is a valid value of each. + unsafe { std::mem::zeroed() } +} + +fn str_of(s: &[u8]) -> AbiStr { + AbiStr { + ptr: s.as_ptr(), + len: s.len(), + } +} + +fn octets(b: &[u8]) -> Blob { + Blob { + ptr: b.as_ptr(), + len: b.len(), + fmt: BLOB_OCTETS, + flags: 0, + } +} + +fn read(s: AbiStr) -> String { + if s.ptr.is_null() || s.len == 0 { + return String::new(); + } + // SAFETY: the door's memory, valid until the instance's next call. + String::from_utf8_lossy(unsafe { std::slice::from_raw_parts(s.ptr, s.len) }).into_owned() +} + +fn head(op: u32, ticket: Ticket) -> InHead { + InHead { + size: std::mem::size_of::() as u32, + op, + ticket, + ..z() + } +} + +fn out_head() -> OutHead { + OutHead { + size: std::mem::size_of::() as u32, + ..z() + } +} + +fn ops() -> &'static Ops { + // SAFETY: the door is a `'static` table whose ops are the plane kind's. + unsafe { &*(*door()).ops.cast::() } +} + +fn lifecycle() -> &'static OpsHead { + &ops().head +} + +struct Tables(HostTables); +// SAFETY: the tables name no function and no memory. +unsafe impl Sync for Tables {} + +static TABLES: Tables = Tables(HostTables { + size: std::mem::size_of::() as u32, + _reserved: 0, + ctx: HostCtx { + ptr: std::ptr::null_mut(), + }, + wake: None, + conns: std::ptr::null(), + io: std::ptr::null(), + services: std::ptr::null(), +}); + +/// The claims a snapshot publishes, `(verb, target)`. +fn claims_of(snapshot: *const PlaneSnapshot) -> Vec<(String, String)> { + if snapshot.is_null() { + return Vec::new(); + } + // SAFETY: the door's snapshot, valid until its generation retires. + let s = unsafe { &*snapshot }; + if s.claims.is_null() { + return Vec::new(); + } + // SAFETY: as above, `claims_len` claims. + unsafe { std::slice::from_raw_parts(s.claims, s.claims_len) } + .iter() + .map(|c| (read(c.verb), read(c.target))) + .collect() +} + +/// The ticket a unit's pieces are pushed on: one per unit. +pub fn ticket_of(unit: u64) -> Ticket { + Ticket { + slot: u32::try_from(unit).expect("a small unit"), + generation: 1, + } +} + +/// One opened instance of the door. +pub struct Door { + instance: *mut c_void, + /// The first generation's claims. + pub claims: Vec<(String, String)>, +} + +/// Open the door over `settings` (the `decisions:` section, as written). +pub fn open(settings: &str) -> Door { + open_with(settings, &[]) +} + +/// Open the door over `settings`, the host stating each referenced provider's `(name, protocol)`. +pub fn open_with(settings: &str, providers: &[(&str, &str)]) -> Door { + let facts: Vec = providers + .iter() + .map(|(name, protocol)| ProviderFacts { + name: str_of(name.as_bytes()), + protocol: str_of(protocol.as_bytes()), + error_map: Blob::ABSENT, + }) + .collect(); + let mut err = [0_u8; 1024]; + let mut i: PlaneOpenIn = z(); + i.open = OpenIn { + head: head::(life::OPEN, Ticket::NONE), + host: &TABLES.0, + settings: octets(settings.as_bytes()), + generation: 1, + err_buf: err.as_mut_ptr(), + err_cap: err.len(), + ..z() + }; + (i.providers, i.providers_len) = (facts.as_ptr(), facts.len()); + let mut o: PlaneOpenOut = z(); + o.open = OpenOut { + head: out_head::(), + ..z() + }; + let f = lifecycle().open.expect("the door opens"); + let ret = f( + std::ptr::null_mut(), + std::ptr::from_ref(&i).cast(), + std::ptr::from_mut(&mut o).cast(), + ); + assert_eq!( + ret.outcome(), + Outcome::Ready, + "the door opens over {settings}" + ); + Door { + instance: o.open.instance, + claims: claims_of(o.snapshot), + } +} + +/// `validate` of the dealt blob `{decisions: }`: the outcome and its words. +pub fn validate(settings: &str) -> (Outcome, String) { + let dealt = format!("{{\"decisions\":{settings}}}"); + let mut err = [0_u8; 1024]; + let mut i: ValidateIn = z(); + i.head = head::(life::VALIDATE, Ticket::NONE); + i.settings = octets(dealt.as_bytes()); + (i.err_buf, i.err_cap) = (err.as_mut_ptr(), err.len()); + let mut o: OutHead = out_head::(); + let f = lifecycle().validate.expect("validate"); + let ret = f( + std::ptr::null_mut(), + std::ptr::from_ref(&i).cast(), + std::ptr::from_mut(&mut o).cast(), + ); + (ret.outcome(), read(o.error)) +} + +/// What `arrive` answered. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Arrived { + pub outcome: Outcome, + /// The plane's own refusal code, `0` unless refused. + pub refusal: u32, + /// The status a refusal wears. + pub status: u32, + /// The refusal's words (`head.error`). + pub text: String, + /// The route class and the entry it names. + pub route: u8, + pub entry: String, +} + +/// What one `on_piece` answered, re-called while it said `more`. +#[derive(Debug, Clone, Default)] +pub struct Answered { + pub outcome: Option, + /// The bytes emitted, over every re-call. + pub bytes: Vec, + /// The bytes were bound for the far end. + pub far: bool, + /// The answer said done. + pub done: bool, + /// The reply status. + pub status: u32, + /// The request line of an ATTEMPT, `" "`. + pub request: String, + /// The head fields written, `(name, value)`. + pub fields: Vec<(String, String)>, + /// The units written, `(class, source, amount)`. + pub units: Vec<(u32, u32, u64)>, + /// Short-answer needs, when the answer was short. + pub short: bool, +} + +/// One piece as the kernel pushes it. +#[derive(Debug, Clone, Copy)] +pub struct Given<'a> { + pub from: u32, + pub flags: u32, + pub status: u32, + pub attempt: u32, + pub bytes: &'a [u8], + /// The dialect `arrive` answered, carried on every piece. + pub dialect: u32, +} + +impl<'a> Given<'a> { + /// The ATTEMPT piece of attempt `n`. + pub const fn attempt(n: u32) -> Self { + Self { + from: FROM_KERNEL, + flags: 0, + status: 0, + attempt: n, + bytes: b"", + dialect: 0, + } + } + + /// The caller's whole body. + pub const fn caller(bytes: &'a [u8]) -> Self { + Self { + from: FROM_CALLER, + flags: PIECE_LAST, + status: 0, + attempt: 0, + bytes, + dialect: 0, + } + } + + /// The far end's whole answer, its status on it. + pub const fn whole(status: u32, bytes: &'a [u8]) -> Self { + Self { + from: FROM_FAR_END, + flags: PIECE_HAS_STATUS | PIECE_LAST, + status, + attempt: 0, + bytes, + dialect: 0, + } + } + + /// One piece of the far end's answer: the first carries `status`, the last says so. + pub const fn far(first: Option, last: bool, bytes: &'a [u8]) -> Self { + let mut flags = 0; + let mut status = 0; + if let Some(s) = first { + flags |= PIECE_HAS_STATUS; + status = s; + } + if last { + flags |= PIECE_LAST; + } + Self { + from: FROM_FAR_END, + flags, + status, + attempt: 0, + bytes, + dialect: 0, + } + } +} + +/// The host buffers one `on_piece` is lent, reused across calls so a measured call allocates only +/// what the door does. +pub struct Bufs { + reply: Vec, + fields: Vec, + arena: Vec, + records: Vec, + units: Vec, +} + +impl Bufs { + pub fn new(reply_cap: usize) -> Self { + Self { + reply: vec![0; reply_cap], + fields: vec![z(); 16], + arena: vec![0; 1 << 14], + records: vec![z(); 4], + units: vec![z(); 8], + } + } +} + +impl Door { + /// `refresh` onto `generation` over `settings`: the new generation's claims. + pub fn refresh(&self, settings: &str, generation: u64) -> Vec<(String, String)> { + let mut i: RefreshIn = z(); + i.head = head::(life::REFRESH, Ticket::NONE); + i.generation = generation; + i.settings = octets(settings.as_bytes()); + let mut o: PlaneRefreshOut = z(); + o.head = out_head::(); + let f = lifecycle().refresh.expect("refresh"); + let ret = f( + self.instance, + std::ptr::from_ref(&i).cast(), + std::ptr::from_mut(&mut o).cast(), + ); + assert_eq!(ret.outcome(), Outcome::Ready, "the door refreshes"); + claims_of(o.snapshot) + } + + /// `arrive` of `unit` on `POST /v1/systemone` with `body`. + pub fn arrive(&self, unit: u64, body: &[u8]) -> Arrived { + self.arrive_on(unit, "POST", "/v1/systemone", body, &[]) + } + + /// `arrive` of `unit` on `verb target` with `body` and the caller's `fields`. + pub fn arrive_on( + &self, + unit: u64, + verb: &str, + target: &str, + body: &[u8], + fields: &[(&str, &str)], + ) -> Arrived { + let fields: Vec = fields + .iter() + .map(|(n, v)| Field { + name: str_of(n.as_bytes()), + value: str_of(v.as_bytes()), + }) + .collect(); + let mut i: ArriveIn = z(); + i.head = head::(slot::ARRIVE, Ticket::NONE); + i.unit = unit; + i.target = str_of(target.as_bytes()); + i.method = str_of(verb.as_bytes()); + i.body = octets(body); + (i.fields, i.fields_len) = (fields.as_ptr(), fields.len()); + let mut units = [z::(); 4]; + (i.units_buf, i.units_cap) = (units.as_mut_ptr(), units.len()); + let mut o: ArriveOut = z(); + o.head = out_head::(); + let f = ops().arrive.expect("arrive"); + let ret = f( + self.instance, + std::ptr::from_ref(&i).cast(), + std::ptr::from_mut(&mut o).cast(), + ); + Arrived { + outcome: ret.outcome(), + refusal: o.refusal, + status: o.refusal_status, + text: read(o.head.error), + route: o.route, + entry: read(o.pool), + } + } + + /// One piece of `unit` on its ticket, re-called while the door says `more`. + pub fn piece(&self, unit: u64, given: Given<'_>, bufs: &mut Bufs) -> Answered { + let ticket = ticket_of(unit); + let mut got = Answered::default(); + let mut first = true; + for _ in 0..1024 { + let mut i: OnPieceIn = z(); + i.head = head::(slot::ON_PIECE, ticket); + i.unit = unit; + i.from = given.from; + i.dialect = given.dialect; + if first { + i.flags = given.flags; + i.bytes = octets(given.bytes); + i.status_code = given.status; + i.attempt_no = given.attempt; + } else { + i.bytes = octets(b""); + } + (i.reply_buf, i.reply_cap) = (bufs.reply.as_mut_ptr(), bufs.reply.len()); + (i.fields_buf, i.fields_cap) = (bufs.fields.as_mut_ptr(), bufs.fields.len()); + (i.arena_buf, i.arena_cap) = (bufs.arena.as_mut_ptr(), bufs.arena.len()); + (i.records_buf, i.records_cap) = (bufs.records.as_mut_ptr(), bufs.records.len()); + (i.units_buf, i.units_cap) = (bufs.units.as_mut_ptr(), bufs.units.len()); + let mut o: OnPieceOut = z(); + o.head = out_head::(); + let f = ops().on_piece.expect("on_piece"); + let ret = f( + self.instance, + std::ptr::from_ref(&i).cast(), + std::ptr::from_mut(&mut o).cast(), + ); + first = false; + got.outcome = Some(ret.outcome()); + got.short |= o.units_needed != 0 || o.fields_needed != 0 || o.arena_needed != 0; + if o.reply_status != 0 { + got.status = o.reply_status; + } + let arena = &bufs.arena; + let at = |s: Span| { + let from = s.offset as usize; + String::from_utf8_lossy(&arena[from..from + s.len as usize]).into_owned() + }; + if ret.outcome() == Outcome::Ready { + got.fields.extend( + bufs.fields[..o.fields_written as usize] + .iter() + .map(|f| (at(f.name), at(f.value))), + ); + got.units.extend( + bufs.units[..o.units_written as usize] + .iter() + .map(|u| (u.class, u.source, u.amount)), + ); + if o.verb.len != 0 { + got.request = format!("{} {}", at(o.verb), at(o.target)); + } + } + got.far |= o.flags & EMIT_TO_FAR_END != 0; + got.done |= o.flags & EMIT_DONE != 0; + got.bytes + .extend_from_slice(&bufs.reply[..usize::try_from(o.emitted).unwrap()]); + if ret.outcome() != Outcome::Ready || o.more == 0 { + break; + } + } + got + } + + /// One crossing of `unit`'s piece that keeps nothing of the answer (so it allocates nothing + /// of its own): the bytes emitted. The answer must fit and be READY. + pub fn piece_bytes(&self, unit: u64, given: Given<'_>, bufs: &mut Bufs) -> usize { + let mut i: OnPieceIn = z(); + i.head = head::(slot::ON_PIECE, ticket_of(unit)); + i.unit = unit; + i.from = given.from; + i.dialect = given.dialect; + i.flags = given.flags; + i.bytes = octets(given.bytes); + i.status_code = given.status; + i.attempt_no = given.attempt; + (i.reply_buf, i.reply_cap) = (bufs.reply.as_mut_ptr(), bufs.reply.len()); + (i.fields_buf, i.fields_cap) = (bufs.fields.as_mut_ptr(), bufs.fields.len()); + (i.arena_buf, i.arena_cap) = (bufs.arena.as_mut_ptr(), bufs.arena.len()); + (i.records_buf, i.records_cap) = (bufs.records.as_mut_ptr(), bufs.records.len()); + (i.units_buf, i.units_cap) = (bufs.units.as_mut_ptr(), bufs.units.len()); + let mut o: OnPieceOut = z(); + o.head = out_head::(); + let f = ops().on_piece.expect("on_piece"); + let ret = f( + self.instance, + std::ptr::from_ref(&i).cast(), + std::ptr::from_mut(&mut o).cast(), + ); + assert!(ret.outcome() == Outcome::Ready && o.more == 0); + usize::try_from(o.emitted).unwrap() + } + + /// `refusal` of `unit` (`0`: none arrived) at `status` with the kernel's `text`; `arrived` + /// says the refusal is the plane's own arrival's. Answers the outcome and the rendered body. + pub fn refusal(&self, unit: u64, status: u32, text: &str, arrived: bool) -> (Outcome, Vec) { + let (mut reply, mut arena) = (vec![0_u8; 4096], vec![0_u8; 1024]); + let mut fields = [z::(); 4]; + let mut i: RefusalIn = z(); + i.head = head::(slot::REFUSAL, Ticket::NONE); + i.cause = if arrived { + REFUSAL_ARRIVE + } else { + REFUSAL_KERNEL + }; + i.status = status; + i.text = str_of(text.as_bytes()); + i.unit = unit; + (i.reply_buf, i.reply_cap) = (reply.as_mut_ptr(), reply.len()); + (i.fields_buf, i.fields_cap) = (fields.as_mut_ptr(), fields.len()); + (i.arena_buf, i.arena_cap) = (arena.as_mut_ptr(), arena.len()); + let mut o: RefusalOut = z(); + o.head = out_head::(); + let f = ops().refusal.expect("refusal"); + let ret = f( + self.instance, + std::ptr::from_ref(&i).cast(), + std::ptr::from_mut(&mut o).cast(), + ); + let n = usize::try_from(o.reply_written).unwrap().min(reply.len()); + (ret.outcome(), reply[..n].to_vec()) + } + + /// `cancel` of the op on `ticket`: the outcome and the disposition. + pub fn cancel(&self, ticket: Ticket) -> (Outcome, u32) { + let mut records = [z::(); 4]; + let mut arena = [0_u8; 256]; + let mut i: PlaneCancelIn = z(); + i.cancel = CancelIn { + head: head::(life::CANCEL, ticket), + ticket, + }; + (i.records_buf, i.records_cap) = (records.as_mut_ptr(), records.len()); + (i.arena_buf, i.arena_cap) = (arena.as_mut_ptr(), arena.len()); + let mut o: PlaneCancelOut = z(); + o.cancel = CancelOut { + head: out_head::(), + ..z() + }; + let f = lifecycle().cancel.expect("cancel"); + let ret = f( + self.instance, + std::ptr::from_ref(&i).cast(), + std::ptr::from_mut(&mut o).cast(), + ); + (ret.outcome(), o.cancel.disposition) + } +} diff --git a/crates/busbar-plane-decisions/tests/jev.rs b/crates/busbar-plane-decisions/tests/jev.rs index 579015acce..035915f459 100644 --- a/crates/busbar-plane-decisions/tests/jev.rs +++ b/crates/busbar-plane-decisions/tests/jev.rs @@ -225,7 +225,10 @@ fn pii_witness_never_surfaces_state_or_answers_in_any_fact() { // behind every assertion above. assert!(!busbar_plane_decisions::codec::RESPONSE_PTRS.contains(&"/state")); assert!(!busbar_plane_decisions::codec::RESPONSE_PTRS.contains(&"/answers")); - assert!(busbar_plane_decisions::codec::REQUEST_PTRS.is_empty()); + assert_eq!( + busbar_plane_decisions::codec::REQUEST_PTRS, + &[busbar_plane_decisions::codec::PTR_MODEL] + ); assert!(!facts::CONTENT_FACTS .iter() .any(|k| *k == "state" || *k == "answers")); diff --git a/crates/busbar-plane-decisions/tests/nanojev.rs b/crates/busbar-plane-decisions/tests/nanojev.rs new file mode 100644 index 0000000000..6f188f5905 --- /dev/null +++ b/crates/busbar-plane-decisions/tests/nanojev.rs @@ -0,0 +1,130 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (C) 2026 Busbar Inc and contributors + +//! THE SECOND DIALECT, `nanojev`, DRIVEN OVER THE DOOR'S ABI (THE DESIGN section 2, the decisions +//! bullet): "NanoJev is the one, dialect `nanojev`: `/api/evaluate` is its second exact claim; a +//! model's dialect is its override or its provider's protocol; a model of the wrong dialect gets +//! that dialect's existing unknown-model refusal; it meters the upstream's own billing unit, +//! counted from the answer already read (`execution.states`; an answer with no count refuses +//! loudly ...), with no extra egress". + +#![allow(unsafe_code)] + +mod door_harness; + +use busbar_contract::abi::mechanism::call::Outcome; +use busbar_contract::abi::plane::{ROUTE_DIRECT, UNITS_REPORTED}; +use door_harness::{open, open_with, validate, Bufs, Given}; + +const SYSTEMONE: (&str, &str) = ("POST", "/v1/systemone"); +const EVALUATE: (&str, &str) = ("POST", "/api/evaluate"); + +/// One model of each dialect: `nano` by its own `protocol`, `jev` by its provider's. +const BOTH: &str = + r#"{"models":{"nano":{"provider":"np","protocol":"nanojev"},"jev":{"provider":"typesafe"}}}"#; + +fn claims(c: &[(&str, &str)]) -> Vec<(String, String)> { + c.iter() + .map(|(v, t)| ((*v).to_string(), (*t).to_string())) + .collect() +} + +/// RED (audit plane-decisions#3): `/api/evaluate` is claimed when a `nanojev` model is configured, +/// by its own `protocol` or its provider's as the host stated it at `open`, and only then. +#[test] +fn evaluate_is_claimed_with_a_nanojev_model() { + assert_eq!(open(BOTH).claims, claims(&[SYSTEMONE, EVALUATE])); + let by_provider = open_with( + r#"{"models":{"n":{"provider":"np"}}}"#, + &[("np", "nanojev")], + ); + assert_eq!(by_provider.claims, claims(&[EVALUATE])); + let jev_only = open_with( + r#"{"models":{"j":{"provider":"typesafe"}}}"#, + &[("typesafe", "jev")], + ); + assert_eq!(jev_only.claims, claims(&[SYSTEMONE])); + // A refresh keeps the provider facts `open` was handed. + assert_eq!( + by_provider.refresh(r#"{"models":{"n2":{"provider":"np"}}}"#, 2), + claims(&[EVALUATE]) + ); +} + +/// RED (audit plane-decisions#3): a request routes among its claim's dialect's models; a model of +/// the other dialect is that dialect's unknown model (404). +#[test] +fn a_model_of_the_wrong_dialect_is_an_unknown_model() { + let door = open(BOTH); + let nano = door.arrive_on(1, EVALUATE.0, EVALUATE.1, br#"{"input":{}}"#, &[]); + assert_eq!( + (nano.outcome, nano.route, nano.entry.as_str()), + (Outcome::Ready, ROUTE_DIRECT, "nano"), + "{nano:?}" + ); + let wrong = door.arrive_on(2, EVALUATE.0, EVALUATE.1, br#"{"model":"jev"}"#, &[]); + assert_eq!( + (wrong.outcome, wrong.status), + (Outcome::Refused, 404), + "{wrong:?}" + ); + let wrong = door.arrive_on(3, SYSTEMONE.0, SYSTEMONE.1, br#"{"model":"nano"}"#, &[]); + assert_eq!( + (wrong.outcome, wrong.status), + (Outcome::Refused, 404), + "{wrong:?}" + ); + let jev = door.arrive(4, br#"{"state":{}}"#); + assert_eq!( + (jev.outcome, jev.entry.as_str()), + (Outcome::Ready, "jev"), + "{jev:?}" + ); +} + +/// A `nanojev` unit served: its ATTEMPT, its body, and the far end's whole answer. +fn served(answer: &[u8]) -> (door_harness::Answered, door_harness::Answered) { + let door = open(BOTH); + let arrived = door.arrive_on(1, EVALUATE.0, EVALUATE.1, br#"{"input":{}}"#, &[]); + assert_eq!(arrived.outcome, Outcome::Ready); + let mut bufs = Bufs::new(1 << 16); + let mut attempt = Given::attempt(1); + attempt.dialect = 1; + let request = door.piece(1, attempt, &mut bufs); + let mut body = Given::caller(br#"{"input":{}}"#); + body.dialect = 1; + assert!(door.piece(1, body, &mut bufs).far); + let mut far = Given::whole(200, answer); + far.dialect = 1; + (request, door.piece(1, far, &mut bufs)) +} + +/// RED (audit plane-decisions#3): the far end is asked at the dialect's own path, and the answer's +/// `execution.states` is the decision count; an answer with none fails loudly. +#[test] +fn a_nanojev_unit_is_sent_to_evaluate_and_bills_its_states() { + let (request, answer) = served(br#"{"execution":{"states":3},"result":{}}"#); + assert_eq!(request.request, "POST /api/evaluate"); + assert_eq!(answer.outcome, Some(Outcome::Ready), "{answer:?}"); + assert_eq!(answer.units[0], (0, UNITS_REPORTED, 3), "{answer:?}"); + let (_, none) = served(br#"{"result":{}}"#); + assert_eq!( + (none.outcome, none.short), + (Some(Outcome::Failed), false), + "{none:?}" + ); + assert!(none.units.is_empty() && none.bytes.is_empty(), "{none:?}"); +} + +/// A model's own `protocol` that names no dialect the plane speaks is refused at validate (#51: +/// fail closed), naming the dialects it does. +#[test] +fn an_unspoken_protocol_is_refused_at_validate() { + let (outcome, words) = validate(r#"{"models":{"m":{"provider":"p","protocol":"bogus"}}}"#); + assert_eq!(outcome, Outcome::Refused, "{words}"); + assert!( + words.contains("bogus") && words.contains("jev, nanojev"), + "{words}" + ); + assert_eq!(validate(BOTH).0, Outcome::Ready); +} diff --git a/crates/busbar-plane-llm/src/codec/chat_handle.rs b/crates/busbar-plane-llm/src/codec/chat_handle.rs index 036c206f0a..e3f7490dc8 100644 --- a/crates/busbar-plane-llm/src/codec/chat_handle.rs +++ b/crates/busbar-plane-llm/src/codec/chat_handle.rs @@ -584,68 +584,43 @@ impl OperationHandler for ChatOperation { &self, ingress_protocol: &str, body: &[u8], - ) -> Option { + ) -> Result, busbar_contract::codec::UsageTapFault> + { // Same-protocol usage tap (verbatim from the former `ChatOperation`; `crate::codec::` re-pathed). + // Each way the tap cannot read the body is RETURNED as its fault: the door carries it out on + // its answer and the kernel counts it and warns once per (protocol, reason) (row 155). The + // codec's own line is the per-request detail, at debug. + use busbar_contract::codec::UsageTapFault; let Some(p) = super::proto_codec::protocol_for(ingress_protocol) else { - if busbar_contract::codec::usage_tap_fault_should_warn( - ingress_protocol, - "unknown_protocol", - ) { - ::tracing::warn!(diag = %crate::codec::diagnostics::USAGE_TAP_UNKNOWN_PROTOCOL.banner(), - protocol = ingress_protocol, - "usage tap: unknown ingress protocol for a same-protocol 2xx body; \ - billing 0 tokens for this request" - ); - } else { - ::tracing::debug!(diag = %crate::codec::diagnostics::USAGE_TAP_UNKNOWN_PROTOCOL.banner(), - protocol = ingress_protocol, - "usage tap: unknown ingress protocol for a same-protocol 2xx body; \ - billing 0 tokens for this request" - ); - } - return None; + ::tracing::debug!(diag = %crate::codec::diagnostics::USAGE_TAP_UNKNOWN_PROTOCOL.banner(), + protocol = ingress_protocol, + "usage tap: unknown ingress protocol for a same-protocol 2xx body; \ + billing 0 tokens for this request" + ); + return Err(UsageTapFault::UnknownProtocol); }; let v = match crate::codec::json::parse::(body) { Ok(v) => v, Err(_e) => { - if busbar_contract::codec::usage_tap_fault_should_warn(ingress_protocol, "bad_json") - { - ::tracing::warn!(diag = %crate::codec::diagnostics::USAGE_TAP_BAD_JSON.banner(), - protocol = ingress_protocol, - error = %crate::codec::json::parse_err_log(body.len()), - "usage tap: failed to parse a same-protocol 2xx body as JSON; \ - billing 0 tokens for this request" - ); - } else { - ::tracing::debug!(diag = %crate::codec::diagnostics::USAGE_TAP_BAD_JSON.banner(), - protocol = ingress_protocol, - error = %crate::codec::json::parse_err_log(body.len()), - "usage tap: failed to parse a same-protocol 2xx body as JSON; \ - billing 0 tokens for this request" - ); - } - return None; + ::tracing::debug!(diag = %crate::codec::diagnostics::USAGE_TAP_BAD_JSON.banner(), + protocol = ingress_protocol, + error = %crate::codec::json::parse_err_log(body.len()), + "usage tap: failed to parse a same-protocol 2xx body as JSON; \ + billing 0 tokens for this request" + ); + return Err(UsageTapFault::BadJson); } }; match p.reader().read_response(&v) { - Ok(ir) => Some(ir.usage.to_token_usage()), + Ok(ir) => Ok(Some(ir.usage.to_token_usage())), Err(e) => { - if busbar_contract::codec::usage_tap_fault_should_warn(ingress_protocol, "decode") { - ::tracing::warn!(diag = %busbar_contract::diagnostic::USAGE_TAP_DECODE_FAILED.banner(), - protocol = ingress_protocol, - error = ?e, - "usage tap: read_response failed to decode a same-protocol 2xx body; \ - billing 0 tokens for this request" - ); - } else { - ::tracing::debug!(diag = %busbar_contract::diagnostic::USAGE_TAP_DECODE_FAILED.banner(), - protocol = ingress_protocol, - error = ?e, - "usage tap: read_response still failing to decode a same-protocol 2xx body; \ - billing 0 tokens for this request" - ); - } - None + ::tracing::debug!(diag = %busbar_contract::diagnostic::USAGE_TAP_DECODE_FAILED.banner(), + protocol = ingress_protocol, + error = ?e, + "usage tap: read_response failed to decode a same-protocol 2xx body; \ + billing 0 tokens for this request" + ); + Err(UsageTapFault::Decode) } } } diff --git a/crates/busbar-plane-llm/src/codec/eventstream.rs b/crates/busbar-plane-llm/src/codec/eventstream.rs index c0ed249d5c..cf56592264 100644 --- a/crates/busbar-plane-llm/src/codec/eventstream.rs +++ b/crates/busbar-plane-llm/src/codec/eventstream.rs @@ -93,8 +93,56 @@ pub enum DrainStatus { /// malformed-prelude remainder (the malformed branch breaks before the push). Pass `None` on the /// cross-protocol path, which re-encodes and needs no verbatim copy. pub fn drain_frames_checked( + buf: &mut Vec, + consumed_sink: Option<&mut Vec>, +) -> (Vec<(String, Vec)>, DrainStatus, usize) { + drain_frames_counted(buf, consumed_sink, &mut DrainWork::default()) +} + +/// The work one drain pass did on its buffer, counted where it is done — the measure the +/// complexity test holds the drain to, instead of wall-clock time (Law 8: a measurement under a +/// shared load must count work units, not time). +/// +/// `scanned` is the bytes walked as complete frames; `edits` the buffer edits; `shifted` the bytes +/// those edits moved (a front `drain` memmoves the whole remaining tail). EVERY edit of the drained +/// buffer goes through [`consume_front`] / [`clear_all`], which count it, so a pass that edits per +/// frame shows up as `edits == frames` and a `shifted` quadratic in the frame count. +// Production passes discard the count; only the complexity test reads it. +#[cfg_attr(not(test), allow(dead_code))] +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub(crate) struct DrainWork { + pub frames: usize, + pub scanned: usize, + pub edits: usize, + pub shifted: usize, +} + +impl DrainWork { + /// Every byte the pass touched: walked as frames, plus moved by an edit. + #[cfg(test)] + pub(crate) fn total(&self) -> usize { + self.scanned + self.shifted + } +} + +/// Remove the first `n` bytes of `buf`, counting the edit and the tail bytes it moves. +fn consume_front(buf: &mut Vec, n: usize, work: &mut DrainWork) { + work.edits += 1; + work.shifted += buf.len() - n; + buf.drain(..n); +} + +/// Empty `buf`, counting the edit (it moves nothing). +fn clear_all(buf: &mut Vec, work: &mut DrainWork) { + work.edits += 1; + buf.clear(); +} + +/// [`drain_frames_checked`], counting its buffer work into `work`. +pub(crate) fn drain_frames_counted( buf: &mut Vec, mut consumed_sink: Option<&mut Vec>, + work: &mut DrainWork, ) -> (Vec<(String, Vec)>, DrainStatus, usize) { let mut out = Vec::new(); let mut status = DrainStatus::Ok; @@ -137,14 +185,14 @@ pub fn drain_frames_checked( sink.extend_from_slice(&rem[..total_len]); } pos += total_len; + work.frames += 1; + work.scanned += total_len; } // Malformed: the stream is unrecoverable, so the valid prefix AND the remainder both go — // `valid_consumed` still reports only the prefix, exactly as before. match status { - DrainStatus::Ok => { - buf.drain(..pos); - } - DrainStatus::MalformedPrelude => buf.clear(), + DrainStatus::Ok => consume_front(buf, pos, work), + DrainStatus::MalformedPrelude => clear_all(buf, work), } (out, status, pos) } diff --git a/crates/busbar-plane-llm/src/codec/gemini/handler.rs b/crates/busbar-plane-llm/src/codec/gemini/handler.rs index d917387928..4d778ef952 100644 --- a/crates/busbar-plane-llm/src/codec/gemini/handler.rs +++ b/crates/busbar-plane-llm/src/codec/gemini/handler.rs @@ -69,11 +69,14 @@ impl RequestHandler for GeminiRequestHandler { if let Some(action) = busbar_contract::codec::path_of(ACTIONS, ctx.operation) { return format!("{base}/{m}:{action}"); } - // Chat + audio understanding/TTS all ride generateContent (stream-aware). So does every - // verb with no cell above, which is unreachable in practice and answers the same thing — - // the pre-1.6.0 answer, verbatim. `stream` is already false for those, because a shape that - // cannot stream never sets it (`OpDispatch::wants_stream`'s shape floor). - let action = if ctx.stream { + // Chat + audio understanding/TTS ride generateContent, stream-aware. Every other verb with + // no action above (moderation, rerank: no Gemini cell) answers `:generateContent` whatever + // `stream` says, exactly as 1.5.5 did (v1.5.5 `crates/busbar/src/handlers/gemini.rs:52-63`: + // only Chat | Transcription | Speech were stream-aware; Moderation and Rerank were + // `:generateContent`). + let stream_aware = + [OpVerb::CHAT, OpVerb::TRANSCRIPTION, OpVerb::SPEECH].contains(&ctx.operation); + let action = if stream_aware && ctx.stream { FIELD_STREAM_GENERATE_CONTENT } else { FIELD_GENERATE_CONTENT @@ -348,11 +351,10 @@ leaf_op! { GeminiImage: COUNT_LABEL, ImageReqHandle = read_image_request, ImageRespHandle = read_image_response; - // Buffer the same-protocol non-stream 2xx body so the default `extract_usage` can read the - // response's usage and bill the virtual key's TPM/spend. Token-metered image models expose a - // `usageMetadata` object (billed as tokens); Imagen `:predict` per-image responses have none, so - // the tap bills 0 tokens and the request still meters once via the per-image cost basis on the - // cross-protocol seam — mirrors the OpenAI image cell. + // Buffer the same-protocol non-stream 2xx body so the default `extract_usage` runs this op's + // reader: a token-reporting image model's `usageMetadata` is RECORDED (never billed, as in + // 1.5.5; see `ImageResp::billing`), and an Imagen `:predict` per-image answer bills its + // `images` count. fn taps_usage(&self) -> bool { true } @@ -407,18 +409,11 @@ pub fn write_image_response(r: &crate::codec::ir::image::ImageResp) -> WireBody p }) .collect(); - let mut body = json!({ (FIELD_PREDICTIONS): predictions }); - // Re-emit the token usage the response reader parses (`read_image_response` captures - // `usageMetadata` for token-metered gemini image models). The old writer dropped it, so a - // token-metered gemini->gemini image response lost its usage in the client-facing body — - // asymmetric with the OpenAI image writer. Emitted only when the IR carries usage. - if let Some(u) = &r.usage { - body[FIELD_USAGE_METADATA] = json!({ - (FIELD_PROMPT_TOKEN_COUNT): u.input, - (FIELD_CANDIDATES_TOKEN_COUNT): u.output, - (FIELD_TOTAL_TOKEN_COUNT): u.input.saturating_add(u.output), - }); - } + // Imagen's `:predict` response is `{predictions}` alone: its `PredictResponse` has no + // `usageMetadata` member, so none is written, exactly as 1.5.5 wrote it (v1.5.5 + // `crates/busbar/src/handlers/gemini.rs:474-492`). A foreign answer's token usage has no Imagen + // member to map to. + let body = json!({ (FIELD_PREDICTIONS): predictions }); WireBody::json(SlabBytes::from( serde_json::to_vec(&body).unwrap_or_default(), )) @@ -838,30 +833,29 @@ pub fn read_image_response(wire: &[u8]) -> Result Result { - // BILLED COUNTS: absent is zero, UNREADABLE IS A REFUSAL (#81/#42) — see - // `usage_count::billed_count`. - Ok(busbar_contract::billing::TokenUsage { - input: crate::codec::usage_count::billed_count(u, FIELD_PROMPT_TOKEN_COUNT) - .map_err(|e| CodecError::Malformed(e.to_string()))?, - output: crate::codec::usage_count::billed_count( - u, - FIELD_CANDIDATES_TOKEN_COUNT, - ) - .map_err(|e| CodecError::Malformed(e.to_string()))?, - ..Default::default() - }) - }, - ) - .transpose()?; + .map(|u| crate::codec::ir::image::ImageTokens { + input: crate::codec::usage_count::recorded_count( + u, + FIELD_PROMPT_TOKEN_COUNT, + &mut warnings, + ), + output: crate::codec::usage_count::recorded_count( + u, + FIELD_CANDIDATES_TOKEN_COUNT, + &mut warnings, + ), + }); // Per-image (Imagen `:predict`) responses carry no `usageMetadata` — record the per-image cost // basis so the op bills as `Billing::Images` rather than nothing. The billable COUNT is // recoverable from the response itself (one image per `predictions` entry). Size/quality tiers @@ -878,6 +872,7 @@ pub fn read_image_response(wire: &[u8]) -> Result &'static match reason { S::EndTurn | S::StopSequence | S::ToolUse => GEMINI_FINISH_STOP, S::MaxTokens => GEMINI_FINISH_MAX_TOKENS, - // A refusal is the model declining on policy grounds — Gemini's SAFETY stop, not an - // unenumerated OTHER (IR audit GEM-14). - S::Safety | S::Refusal => GEMINI_FINISH_SAFETY, - S::Error | S::PauseTurn | S::Other => GEMINI_FINISH_OTHER, + S::Safety => GEMINI_FINISH_SAFETY, + // A refusal (the model declining) has no Gemini finishReason: it is written as the native + // unenumerated `OTHER`, exactly as 1.5.5 wrote it (v1.5.5 + // `crates/busbar/src/proto/gemini/mod.rs:771`). `SAFETY` names a safety filter, which a + // refusal is not, so writing it would substitute a value the source never stated. + S::Refusal | S::Error | S::PauseTurn | S::Other => GEMINI_FINISH_OTHER, } } diff --git a/crates/busbar-plane-llm/src/codec/gemini/tests/float_usage_tests.rs b/crates/busbar-plane-llm/src/codec/gemini/tests/float_usage_tests.rs index 336c3eb3eb..313ac9d05d 100644 --- a/crates/busbar-plane-llm/src/codec/gemini/tests/float_usage_tests.rs +++ b/crates/busbar-plane-llm/src/codec/gemini/tests/float_usage_tests.rs @@ -170,27 +170,65 @@ fn additive_sum_is_correct_when_exactly_one_term_is_float_spelled() { // `usageMetadata` field that is there and is not a number busbar understands. The old readers // defaulted it to zero, writing "no work happened" into the ledger for work that did. -/// THE BUFFERED IMAGE READER REFUSES AN UNREADABLE BILLED COUNT. +/// AN UNREADABLE IMAGE COUNT IS RECORDED AS UNKNOWN: THE ANSWER IS SERVED, THE COUNT NEVER 0. +/// +/// The image tokens are recorded (residual units), never billed. 1.5.5 served the answer whatever +/// its usage said (v1.5.5 `crates/busbar/src/handlers/gemini.rs:446-472` read no usage at all), so +/// an unreadable count must not refuse it; and a recorded count is a ledger row, so it must not +/// become a fabricated 0 either (#81, OWNER rule 21). It is UNKNOWN: left out of the record, named +/// on the answer's warnings. The re-emitted Imagen body carries no `usageMetadata` at all (1.5.5's +/// `{predictions}` alone, v1.5.5 `crates/busbar/src/handlers/gemini.rs:474-492`), so no 0 can be +/// written for it there. RED while `gemini/handler.rs` `read_image_response` defaulted it to zero, +/// and while it refused the answer. #[test] -fn an_unreadable_image_count_refuses_instead_of_billing_zero() { +fn an_unreadable_image_count_is_recorded_as_unknown_and_the_answer_served() { + // `"27"` is a string, not a number: present, and not a count. let wire = br#"{"predictions":[{"bytesBase64Encoded":"aGk="}],"usageMetadata":{"promptTokenCount":"27","candidatesTokenCount":5}}"#; - let err = crate::codec::gemini::handler::read_image_response(wire) - .expect_err("a present, unreadable billed count is a refusal, never a zero"); + let resp = crate::codec::gemini::handler::read_image_response(wire) + .expect("the answer is served whatever its usage said, as in 1.5.5"); + assert_eq!(resp.images.len(), 1, "the image is served"); + assert_eq!( + resp.usage, + Some(crate::codec::ir::image::ImageTokens { + input: None, + output: Some(5), + }), + "the unreadable count is unknown, not 0; the readable one is read" + ); assert!( - format!("{err:?}").contains("promptTokenCount"), - "the refusal names the field: {err:?}" + resp.warnings.iter().any(|w| w.contains("promptTokenCount")), + "the unreadable field is named: {:?}", + resp.warnings + ); + match resp.billing() { + Some(busbar_contract::billing::Billing::Tokens(t)) => { + assert_eq!((t.input, t.output), (0, 0), "image tokens never bill"); + assert_eq!( + t.residual_units, + std::collections::BTreeMap::from([("image.output_tokens".to_string(), 5)]), + "the unknown count is left out of the record, never recorded as 0" + ); + } + other => panic!("expected a zero token line, got {other:?}"), + } + let written: serde_json::Value = + serde_json::from_slice(&crate::codec::gemini::handler::write_image_response(&resp).bytes) + .unwrap(); + assert!( + written.get("usageMetadata").is_none(), + "the Imagen body is `{{predictions}}` alone, as in 1.5.5; no usage (and so no 0) is written: {written}" ); - // The same body, float-spelled, still reads: the refusal is about unreadability only. + // The same body, float-spelled, reads and is recorded: only an unreadable count is unknown. let ok = br#"{"predictions":[{"bytesBase64Encoded":"aGk="}],"usageMetadata":{"promptTokenCount":27.0,"candidatesTokenCount":5}}"#; let resp = crate::codec::gemini::handler::read_image_response(ok) .expect("a float-spelled count reads"); match resp.billing() { Some(busbar_contract::billing::Billing::Tokens(t)) => { - assert_eq!(t.input, 27); - assert_eq!(t.output, 5); + assert_eq!((t.input, t.output), (0, 0)); + assert_eq!(t.residual_units.get("image.input_tokens"), Some(&27)); } - other => panic!("expected token billing, got {other:?}"), + other => panic!("expected a zero token line, got {other:?}"), } } diff --git a/crates/busbar-plane-llm/src/codec/gemini/tests/handler_tests.rs b/crates/busbar-plane-llm/src/codec/gemini/tests/handler_tests.rs index 8bc24feb64..85cac73516 100644 --- a/crates/busbar-plane-llm/src/codec/gemini/tests/handler_tests.rs +++ b/crates/busbar-plane-llm/src/codec/gemini/tests/handler_tests.rs @@ -63,6 +63,42 @@ fn path_base_reshapes_the_gemini_url_for_vertex() { ); } +/// Moderation and rerank (no Gemini cell) answer `:generateContent` whether or not the request +/// streams, as 1.5.5 did (v1.5.5 `crates/busbar/src/handlers/gemini.rs:62-63`); only chat, +/// transcription and speech are stream-aware (`:52-58`). RED while every fallback verb took +/// `:streamGenerateContent` on a streamed request. +#[test] +fn moderation_and_rerank_always_use_generate_content() { + let h = GeminiRequestHandler; + let model = "gemini-2.0-flash"; + for operation in [OpVerb::MODERATION, OpVerb::RERANK] { + for stream in [false, true] { + assert_eq!( + h.upstream_path(&EgressCtx { + operation, + model, + stream, + path_base: None, + }), + "/v1beta/models/gemini-2.0-flash:generateContent", + "{operation:?} stream={stream}" + ); + } + } + for operation in [OpVerb::CHAT, OpVerb::TRANSCRIPTION, OpVerb::SPEECH] { + assert_eq!( + h.upstream_path(&EgressCtx { + operation, + model, + stream: true, + path_base: None, + }), + "/v1beta/models/gemini-2.0-flash:streamGenerateContent", + "{operation:?}" + ); + } +} + #[test] fn transcription_read_request_captures_inline_audio() { // A generateContent body with a valid-base64 inline_data audio part → IR Transcription @@ -308,11 +344,12 @@ fn image_read_response_captures_base64_image_bytes() { assert_eq!(r.images[0].mime_type.as_deref(), Some("image/png")); } -// FIND (money): a token-metered Gemini image response carries a `usageMetadata` token object; the -// reader must surface it so `billing()` token-meters the request instead of billing nothing. Fails -// pre-fix (usage left unset → `billing()` is None). +// A token-reporting Gemini image response carries a `usageMetadata` token object. Its tokens are +// RECORDED (residual units) and bill NOTHING on the four token tiers, as in 1.5.5, whose Imagen +// reader read no `usageMetadata` (v1.5.5 `crates/busbar/src/handlers/gemini.rs:446-472`) and whose +// image op tapped no same-dialect body. RED while the reader token-billed 20 in / 10 out. #[test] -fn image_response_with_usage_metadata_bills_tokens() { +fn image_response_usage_metadata_is_recorded_never_billed() { let wire = serde_json::to_vec(&json!({ "predictions": [{ "bytesBase64Encoded": "AAAA", "mimeType": "image/png" }], "usageMetadata": { "promptTokenCount": 20, "candidatesTokenCount": 10 }, @@ -321,10 +358,18 @@ fn image_response_with_usage_metadata_bills_tokens() { let resp = super::read_image_response(&wire).unwrap(); match resp.billing() { Some(busbar_contract::billing::Billing::Tokens(t)) => { - assert_eq!(t.input, 20); - assert_eq!(t.output, 10); + assert_eq!((t.input, t.output), (0, 0), "no image token bills: {t:?}"); + assert_eq!( + t.residual_units, + std::collections::BTreeMap::from([ + ("image.input_tokens".to_string(), 20), + ("image.output_tokens".to_string(), 10), + ]) + ); + } + other => { + panic!("a token-reporting Gemini image answer is a zero token line, got {other:?}") } - other => panic!("token-metered Gemini image must token-bill, got {other:?}"), } } @@ -838,43 +883,28 @@ fn transcription_response_reads_token_usage() { ); } -// L1: a token-metered gemini image response must re-emit `usageMetadata` in the client-facing body -// (asymmetric with the OpenAI image writer before). Fails pre-fix: the writer dropped usage. +// An Imagen `:predict` response is `{predictions}` alone, as 1.5.5 wrote it (v1.5.5 +// `crates/busbar/src/handlers/gemini.rs:474-492`): Imagen's `PredictResponse` has no `usageMetadata`, +// so a foreign answer's token usage is not written into one. RED while the writer invented it. #[test] -fn image_response_writer_round_trips_usage() { +fn image_response_writer_writes_no_usage_metadata() { let resp = crate::codec::ir::image::ImageResp { images: vec![busbar_contract::media::ImageOutput { b64: Some("aGVsbG8=".into()), ..Default::default() }], - usage: Some(busbar_contract::billing::TokenUsage { - input: 10, - output: 5, - ..Default::default() + usage: Some(crate::codec::ir::image::ImageTokens { + input: Some(10), + output: Some(5), }), ..Default::default() }; let wb = write_image_response(&resp); let v: Value = serde_json::from_slice(&wb.bytes).unwrap(); + assert!(v.get("usageMetadata").is_none(), "{v}"); assert_eq!( - v.pointer("/usageMetadata/promptTokenCount"), - Some(&json!(10)), - "image usage must be emitted: {v}" - ); - assert_eq!( - v.pointer("/usageMetadata/candidatesTokenCount"), - Some(&json!(5)) - ); - // And it round-trips back through the response reader instead of being dropped. - let back = read_image_response(&wb.bytes).expect("re-read"); - assert_eq!( - back.usage, - Some(busbar_contract::billing::TokenUsage { - input: 10, - output: 5, - ..Default::default() - }), - "image usage must round-trip through write->read" + v.pointer("/predictions/0/bytesBase64Encoded"), + Some(&json!("aGVsbG8=")) ); } diff --git a/crates/busbar-plane-llm/src/codec/gemini/tests/ir_mapping_tests.rs b/crates/busbar-plane-llm/src/codec/gemini/tests/ir_mapping_tests.rs index 7a86b6b1ad..8c488093c7 100644 --- a/crates/busbar-plane-llm/src/codec/gemini/tests/ir_mapping_tests.rs +++ b/crates/busbar-plane-llm/src/codec/gemini/tests/ir_mapping_tests.rs @@ -328,16 +328,18 @@ fn gem12_audio_token_details_reach_openai() { assert_eq!(out["usage"]["completion_tokens"], json!(8), "{out}"); } -/// GEM-14: a refusal reaches a Gemini client as its policy stop `SAFETY`, not `OTHER`. +/// A refusal reaches a Gemini client as the native unenumerated `OTHER`, as in 1.5.5 (v1.5.5 +/// `crates/busbar/src/proto/gemini/mod.rs:771`): Gemini has no refusal stop, and `SAFETY` names a +/// safety filter. RED while the writer wrote `SAFETY`. #[test] -fn gem14_refusal_is_safety_for_a_gemini_client() { +fn a_refusal_is_other_for_a_gemini_client() { let body = json!({"id": "msg_1", "type": "message", "role": "assistant", "model": "m", "content": [{"type": "text", "text": "no"}], "stop_reason": "refusal", "stop_sequence": null, "usage": {"input_tokens": 1, "output_tokens": 1}}); let out = xresp("anthropic", "gemini", &body); assert_eq!( out["candidates"][0]["finishReason"], - json!("SAFETY"), + json!("OTHER"), "{out}" ); } diff --git a/crates/busbar-plane-llm/src/codec/gemini/tests/tests.rs b/crates/busbar-plane-llm/src/codec/gemini/tests/tests.rs index a7df7cfc21..2072b7c1c4 100644 --- a/crates/busbar-plane-llm/src/codec/gemini/tests/tests.rs +++ b/crates/busbar-plane-llm/src/codec/gemini/tests/tests.rs @@ -1498,14 +1498,15 @@ fn test_response_identity_roundtrip_preserves_id_and_model() { /// F2 conformance: a foreign / novel IR stop_reason (`refusal` from Responses, `error` from /// Cohere) must NOT upper-case-leak into `finishReason` (e.g. "REFUSAL"/"ERROR" are outside /// Gemini's `FinishReason` enum and a strict google-genai client rejects them). `error`/`other` map -/// to the native `OTHER` member and a `refusal` to its policy stop `SAFETY` (IR audit GEM-14), on -/// both the whole-body and streamed paths. +/// to the native `OTHER` member, and so does a `refusal` (Gemini has no refusal stop; 1.5.5 wrote +/// `OTHER`, v1.5.5 `crates/busbar/src/proto/gemini/mod.rs:771`), on both the whole-body and streamed +/// paths. #[test] fn foreign_stop_reason_maps_to_other_not_verbatim() { use crate::codec::ir::IrStopReason as S; let writer = GeminiWriter; for (foreign, native) in [ - (S::Refusal, "SAFETY"), + (S::Refusal, "OTHER"), (S::Error, "OTHER"), (S::Other, "OTHER"), ] { diff --git a/crates/busbar-plane-llm/src/codec/ir/image.rs b/crates/busbar-plane-llm/src/codec/ir/image.rs index 7c974ced86..5e695c2c4a 100644 --- a/crates/busbar-plane-llm/src/codec/ir/image.rs +++ b/crates/busbar-plane-llm/src/codec/ir/image.rs @@ -9,7 +9,9 @@ //! `tier`) are PARALLEL optionals — never collapsed. Response images are additive [`ImageOutput`] //! (b64 AND url may coexist). Common cross-provider fields are typed; provider-unique knobs (Titan //! `controlMode`, SDXL `sampler`/`clip_guidance_preset`, per-prompt weights…) ride source-scoped -//! `extra`. Billing: `Tokens` for gpt-image-1/Gemini, else `Billing::Images` (per-image, no usage body). +//! `extra`. Billing: the token counts a gpt-image-1 or Gemini answer reports are RECORDED, never +//! billed (1.5.5 billed no image tokens; see [`ImageResp::billing`]); else `Billing::Images` +//! (per-image, no usage body). use busbar_contract::billing::{Billing, TokenUsage}; use busbar_contract::ir::SourceScopedExtra; @@ -177,17 +179,55 @@ pub struct CostBasis { pub struct ImageResp { pub created: Option, pub images: Vec, // additive per image (b64 AND url may coexist) - pub usage: Option, // gpt-image-1, Gemini (token-metered) + pub usage: Option, // gpt-image-1, Gemini: reported tokens (recorded, not billed) pub cost_basis: Option, // per-image providers (dall-e/Imagen/Titan/SDXL) pub warnings: Vec, // raiFilteredReason, finish_reasons, moderation notes pub extra: SourceScopedExtra, } +/// The token counts an image answer reported (gpt-image-1 `usage`, a Gemini image model's +/// `usageMetadata`). They are RECORDED, never billed (see [`ImageResp::billing`]). `None` is +/// UNKNOWN: the provider sent a count this build cannot read. An unknown count is left out of the +/// record, never written as 0, and never refuses the answer (1.5.5 served it whatever the usage +/// said). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct ImageTokens { + pub input: Option, + pub output: Option, +} + +/// The residual key an image answer's reported INPUT token count is recorded under. +pub const IMAGE_INPUT_TOKENS_RESIDUAL: &str = "image.input_tokens"; +/// The residual key an image answer's reported OUTPUT token count is recorded under. +pub const IMAGE_OUTPUT_TOKENS_RESIDUAL: &str = "image.output_tokens"; + impl ImageResp { - /// Billing projection: token usage when present (gpt-image-1/Gemini); else per-image `Images`. + /// Billing projection. An answer that reports token usage (gpt-image-1, a Gemini image model) + /// bills NO token: its counts are RECORDED on [`TokenUsage::residual_units`] (the settle's one + /// `usage.residual` audit row), never on the four billed tiers. 1.5.5 billed no image token on + /// any path: its OpenAI, Gemini and Bedrock image readers set neither `usage` nor `cost_basis` + /// (v1.5.5 `crates/busbar/src/handlers/openai.rs:731-758`, `handlers/gemini.rs:446-472`), and + /// no image handler tapped a same-dialect body (the only `taps_usage` overrides were + /// embeddings and chat). Billing the tokens would charge a key, and spend its `tokens:` caps, + /// where 1.5.5 charged nothing, and no owner ruling signs that (LEDGER-100 records a reported + /// unit; it does not sign a new charge). A per-image answer with no usage body bills its + /// `images` count (LEDGER-100, CHANGELOG 1.6.0 Upgrade). pub fn billing(&self) -> Option { if let Some(u) = &self.usage { - Some(Billing::Tokens(u.clone())) + let mut residual_units = std::collections::BTreeMap::new(); + for (class, n) in [ + (IMAGE_INPUT_TOKENS_RESIDUAL, u.input), + (IMAGE_OUTPUT_TOKENS_RESIDUAL, u.output), + ] { + // An unknown count (`None`) is left out, as a zero is: neither is a reported unit. + if let Some(n) = n.filter(|n| *n != 0) { + residual_units.insert(class.to_string(), n); + } + } + Some(Billing::Tokens(TokenUsage { + residual_units, + ..Default::default() + })) } else { self.cost_basis.as_ref().map(|cb| Billing::Images { count: cb.count, diff --git a/crates/busbar-plane-llm/src/codec/ir/tests/image_tests.rs b/crates/busbar-plane-llm/src/codec/ir/tests/image_tests.rs index 3e40397302..e5927e18e9 100644 --- a/crates/busbar-plane-llm/src/codec/ir/tests/image_tests.rs +++ b/crates/busbar-plane-llm/src/codec/ir/tests/image_tests.rs @@ -24,14 +24,21 @@ fn geometry_conventions_are_parallel_not_collapsed() { #[test] fn billing_prefers_tokens_else_per_image() { let tokenized = ImageResp { - usage: Some(TokenUsage { - input: 5, - output: 272, - ..Default::default() + usage: Some(super::ImageTokens { + input: Some(5), + output: Some(272), }), ..Default::default() }; - assert!(matches!(tokenized.billing(), Some(Billing::Tokens(_)))); + // Image tokens are recorded, never billed (1.5.5 billed no image token). + match tokenized.billing() { + Some(Billing::Tokens(t)) => { + assert_eq!((t.input, t.output), (0, 0)); + assert_eq!(t.residual_units.get("image.input_tokens"), Some(&5)); + assert_eq!(t.residual_units.get("image.output_tokens"), Some(&272)); + } + other => panic!("expected a zero token line, got {other:?}"), + } let per_image = ImageResp { cost_basis: Some(CostBasis { diff --git a/crates/busbar-plane-llm/src/codec/openai_chat/handler.rs b/crates/busbar-plane-llm/src/codec/openai_chat/handler.rs index cb9b674fd4..e0e79816ce 100644 --- a/crates/busbar-plane-llm/src/codec/openai_chat/handler.rs +++ b/crates/busbar-plane-llm/src/codec/openai_chat/handler.rs @@ -249,58 +249,29 @@ leaf_op! { TranscriptionReqHandle = read_transcription_request, TranscriptionRespHandle = read_transcription_response; fn egress_request_content_type(&self) -> &'static str { - // write_request rebuilds the multipart form around `transcription_boundary()`, which is - // drawn from entropy rather than hard-coded. Both sides read the same value, so the header - // always names the boundary the body is actually framed with. - static CT: std::sync::OnceLock = std::sync::OnceLock::new(); - CT.get_or_init(|| format!("multipart/form-data; boundary={}", transcription_boundary())) - .as_str() + // write_request rebuilds the multipart form around `TRANSCRIPTION_BOUNDARY`. + TRANSCRIPTION_CONTENT_TYPE } } -/// The multipart boundary the transcription egress form is framed with. +/// The multipart boundary the transcription egress form is framed with: 1.5.5's literal (v1.5.5 +/// `crates/busbar/src/handlers/openai.rs:238`, `:281`), a constant, so the plugin keeps no +/// process-global state for it (BUSBAR-1.6.0.md THE DESIGN §2: "A plugin holds no process-global +/// state"; the per-process `OnceLock` draw it replaces was such state) and the upstream receives the +/// bytes 1.5.5 sent. /// -/// It CANNOT be a literal in the source. The audio part carries raw client-controlled bytes, so a -/// client that knows the delimiter can embed `\r\n--` in its audio, close the file part -/// early, and append parts of its own — e.g. a second `model` field, which a last-occurrence-wins -/// multipart parser upstream honours while busbar bills the model the lane resolved. That reaches -/// busbar cross-protocol (a Gemini-dialect transcription ingress accepts base64 audio and this -/// writer re-frames it for OpenAI egress), so the bytes are attacker-chosen in practice. -/// -/// So the suffix is drawn from the crate's entropy seam ([`crate::codec::synth_rng::fill_entropy`], the -/// same OS-CSPRNG pool the synthesized wire ids use). 16 random bytes → 32 hex chars, giving a -/// 42-char boundary: inside RFC 2046's 70-char limit and drawn only from its `bchars` set. -/// -/// PER PROCESS, NOT PER REQUEST. [`OperationHandler::egress_request_content_type`] declares the -/// Content-Type as a `&'static str` and is called by the engine independently of the body write, so -/// the two can only agree on a boundary that outlives the request — and if they disagree the -/// upstream cannot parse the form at all. A per-process draw already denies the attacker a -/// delimiter they can compute from the source; unpredictability is not the guarantee, though — -/// [`part_carries_delimiter`] fails the request closed even if the value were somehow known. +/// A client that knows the delimiter cannot use it: the audio part carries raw client-controlled +/// bytes, so a payload embedding `\r\n--` could close the file part early and append a +/// second `model` field — and [`part_carries_delimiter`] refuses exactly that, failing the whole body +/// closed. That check, not the delimiter's secrecy, is the guarantee. +pub const TRANSCRIPTION_BOUNDARY: &str = "----busbaraudioMIME"; + +/// The Content-Type the transcription egress form is declared with; names [`TRANSCRIPTION_BOUNDARY`]. +const TRANSCRIPTION_CONTENT_TYPE: &str = "multipart/form-data; boundary=----busbaraudioMIME"; + +/// The transcription egress boundary ([`TRANSCRIPTION_BOUNDARY`]). pub fn transcription_boundary() -> &'static str { - static BOUNDARY: std::sync::OnceLock = std::sync::OnceLock::new(); - BOUNDARY - .get_or_init(|| { - let mut raw = [0u8; 16]; - // Entropy failure is not fatal here: the delimiter check below is what makes the body - // safe, and this value's only other job is to be a legal, unlikely-to-collide token. - // Mix the clock in so a broken CSPRNG does not pin every process to one known string. - if !crate::codec::synth_rng::fill_entropy(&mut raw) { - let nanos = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_nanos()) - .unwrap_or(0); - raw.copy_from_slice(&nanos.to_le_bytes()); - } - let mut s = String::with_capacity(42); - s.push_str("----busbar"); - for b in raw { - s.push(char::from_digit((b >> 4) as u32, 16).unwrap_or('0')); - s.push(char::from_digit((b & 0x0f) as u32, 16).unwrap_or('0')); - } - s - }) - .as_str() + TRANSCRIPTION_BOUNDARY } /// Does `part` contain the multipart delimiter, i.e. can splicing it into a body framed with @@ -321,7 +292,7 @@ fn part_carries_delimiter(part: &[u8], boundary: &str) -> bool { /// IR → openai multipart transcription request wire (the body of /// [`OpenAiTranscription::write_request`], moved behind the `(transcription, openai)` key — G6 A4b -/// option-a). OpenAI-as-egress rebuilds the multipart form around [`transcription_boundary`]; not on +/// option-a). OpenAI-as-egress rebuilds the multipart form around [`TRANSCRIPTION_BOUNDARY`]; not on /// the harness path (openai is always ingress there), kept for cross-protocol symmetry. /// /// Returns an EMPTY body if any part carries the delimiter. The seam types this write as infallible @@ -652,11 +623,9 @@ leaf_op! { OpenAiImage: super::VENDOR_NAME, ImageReqHandle = read_image_request, ImageRespHandle = read_image_response; - // Token-metered for gpt-image-1: buffer the same-protocol non-stream 2xx body so the default - // `extract_usage` can read the `usage` object and bill the virtual key's TPM/spend. The - // cross-protocol path already bills via `translate_response`; this closes the same-protocol gap - // (mirrors the embeddings cell). Per-image models return no `usage`, so the tap bills 0 tokens - // and the request still meters once — unchanged for them. + // Buffer the same-protocol non-stream 2xx body so the default `extract_usage` runs this op's + // reader: a gpt-image-1 answer's reported tokens are RECORDED (never billed, as in 1.5.5; see + // `ImageResp::billing`), and a per-image answer with no `usage` bills its `images` count. fn taps_usage(&self) -> bool { true } @@ -739,12 +708,20 @@ pub fn write_image_response(r: &ImageResp) -> WireBody { // gpt-image-1 returns a token `usage` object; the reader parses it (for billing) but the writer // dropped it, so a same-/cross-protocol image hop lost the usage the client should see. Re-emit // it in OpenAI's own image-usage shape when present. + // An UNKNOWN count (one the reader could not read) is left out rather than written as 0, and + // so is the total it would have summed into. if let Some(u) = &r.usage { - body[keys::USAGE] = json!({ - (keys::INPUT_TOKENS): u.input, - (keys::OUTPUT_TOKENS): u.output, - (keys::TOTAL_TOKENS): u.input.saturating_add(u.output), - }); + let mut usage = serde_json::Map::new(); + if let Some(n) = u.input { + usage.insert(keys::INPUT_TOKENS.into(), json!(n)); + } + if let Some(n) = u.output { + usage.insert(keys::OUTPUT_TOKENS.into(), json!(n)); + } + if let (Some(i), Some(o)) = (u.input, u.output) { + usage.insert(keys::TOTAL_TOKENS.into(), json!(i.saturating_add(o))); + } + body[keys::USAGE] = Value::Object(usage); } WireBody::json(SlabBytes::from( serde_json::to_vec(&body).unwrap_or_default(), @@ -1365,24 +1342,24 @@ pub fn read_image_response(wire: &[u8]) -> Result Result { - // BILLED COUNTS: absent is zero, UNREADABLE IS A REFUSAL (#81/#42). - Ok(busbar_contract::billing::TokenUsage { - input: crate::codec::usage_count::billed_count(u, keys::INPUT_TOKENS) - .map_err(|e| CodecError::Malformed(e.to_string()))?, - output: crate::codec::usage_count::billed_count(u, keys::OUTPUT_TOKENS) - .map_err(|e| CodecError::Malformed(e.to_string()))?, - ..Default::default() - }) - }, - ) - .transpose()?; + .map(|u| crate::codec::ir::image::ImageTokens { + input: crate::codec::usage_count::recorded_count(u, keys::INPUT_TOKENS, &mut warnings), + output: crate::codec::usage_count::recorded_count( + u, + keys::OUTPUT_TOKENS, + &mut warnings, + ), + }); // Per-image (dall-e-style) providers carry no `usage` — record the per-image cost basis so the // op is billed as `Billing::Images` rather than nothing. The billable COUNT is recoverable from // the response itself (one image per `data` entry). The size/quality TIERS live on the request @@ -1400,6 +1377,7 @@ pub fn read_image_response(wire: &[u8]) -> Result { + assert_eq!((t.input, t.output), (0, 0), "image tokens never bill"); + assert_eq!( + t.residual_units, + std::collections::BTreeMap::from([("image.output_tokens".to_string(), 5)]), + "the unknown count is left out of the record, never recorded as 0" + ); + } + other => panic!("expected a zero token line, got {other:?}"), + } + let written: serde_json::Value = serde_json::from_slice( + &crate::codec::openai_chat::handler::write_image_response(&resp).bytes, + ) + .unwrap(); + assert_eq!( + written.pointer("/usage/input_tokens"), + None, + "no 0 is written for it: {written}" + ); + assert_eq!( + written.pointer("/usage/total_tokens"), + None, + "nor a total that would include it" + ); + assert_eq!( + written.pointer("/usage/output_tokens"), + Some(&serde_json::json!(5)) ); - // And the same body with a READABLE count still reads, float-spelled included — the refusal is - // about unreadability, not about strictness for its own sake. + // The same body, float-spelled, reads and is recorded: only an unreadable count is unknown. let ok = br#"{"data":[{"b64_json":"aGk="}],"usage":{"input_tokens":27.0,"output_tokens":5}}"#; let resp = crate::codec::openai_chat::handler::read_image_response(ok) .expect("a float-spelled count reads"); match resp.billing() { Some(busbar_contract::billing::Billing::Tokens(t)) => { - assert_eq!(t.input, 27); - assert_eq!(t.output, 5); + assert_eq!((t.input, t.output), (0, 0)); + assert_eq!(t.residual_units.get("image.input_tokens"), Some(&27)); + assert_eq!(t.residual_units.get("image.output_tokens"), Some(&5)); } - other => panic!("expected token billing, got {other:?}"), + other => panic!("expected a zero token line, got {other:?}"), } } @@ -183,10 +229,14 @@ fn an_absent_count_still_reads_as_zero_and_no_usage_object_still_reads() { .expect("absent output_tokens reads"); match resp.billing() { Some(busbar_contract::billing::Billing::Tokens(t)) => { - assert_eq!(t.input, 27); - assert_eq!(t.output, 0, "an absent count is zero, exactly as before"); + assert_eq!((t.input, t.output), (0, 0), "image tokens never bill"); + assert_eq!( + t.residual_units, + std::collections::BTreeMap::from([("image.input_tokens".to_string(), 27)]), + "an absent count is zero, and a zero is not recorded" + ); } - other => panic!("expected token billing, got {other:?}"), + other => panic!("expected a zero token line, got {other:?}"), } // No `usage` object at all: unchanged, bills via the per-image cost basis. let wire = br#"{"data":[{"b64_json":"aGk="}]}"#; diff --git a/crates/busbar-plane-llm/src/codec/openai_chat/tests/handler_tests.rs b/crates/busbar-plane-llm/src/codec/openai_chat/tests/handler_tests.rs index b4f5ade5a4..f03d68b277 100644 --- a/crates/busbar-plane-llm/src/codec/openai_chat/tests/handler_tests.rs +++ b/crates/busbar-plane-llm/src/codec/openai_chat/tests/handler_tests.rs @@ -315,11 +315,11 @@ fn transcription_egress_field_strips_crlf_injection() { assert_eq!(text.matches(&delim).count(), 3); } -/// The audio part carries raw client-controlled bytes. With a boundary the client can predict, a -/// payload that embeds `\r\n--` closes the file part early and appends parts of its own — -/// here a second `model` field. Parsers that take the LAST occurrence then run the request against -/// the client's model while the lane bills the model busbar chose. Fails pre-fix (the writer used a -/// hard-coded boundary and spliced the audio bytes in unchecked): the body carried two `model` parts. +/// The audio part carries raw client-controlled bytes. A payload that embeds `\r\n--` +/// would close the file part early and append parts of its own — here a second `model` field, which +/// a last-occurrence-wins parser upstream honours while busbar bills the model it chose. The +/// boundary is 1.5.5's literal, so the client knows it: the writer refuses the body outright (an +/// empty, fail-closed body), and no second `model` part is ever framed. #[test] fn transcription_egress_audio_cannot_smuggle_a_second_model_part() { let smuggled = b"RIFF\r\n------busbaraudioMIME\r\nContent-Disposition: form-data; \ @@ -334,35 +334,20 @@ fn transcription_egress_audio_cannot_smuggle_a_second_model_part() { ..Default::default() }; let out = super::super::super::leaf_codec::transcription_write_request("openai", &ir); - let text = String::from_utf8_lossy(&out); - // The smuggled text may still sit INSIDE the audio bytes — harmless, since it is no longer - // introduced by a delimiter the upstream parser recognises. What must not exist is a SECOND - // part: exactly one `model` field may be framed by the live boundary. - let model_part = format!( - "--{}\r\nContent-Disposition: form-data; name=\"model\"", - transcription_boundary() - ); - assert_eq!( - text.matches(&model_part).count(), - 1, - "audio bytes must not be able to append a second model part: {text}" + assert!( + out.is_empty(), + "a payload carrying the live delimiter fails the body closed: {}", + String::from_utf8_lossy(&out) ); } -/// The boundary must not be the hard-coded literal a client could copy out of the source: it is -/// drawn from the crate's entropy seam, is a legal RFC 2046 boundary, and the Content-Type the -/// engine sends upstream must name the SAME boundary the body actually uses (otherwise the upstream -/// cannot parse the form at all). +/// The boundary is 1.5.5's literal (v1.5.5 `crates/busbar/src/handlers/openai.rs:238`, `:281`), a +/// constant the plugin holds no state for, and the Content-Type the engine sends upstream names the +/// SAME boundary the body is framed with. RED while it was a per-process `OnceLock` draw. #[test] -fn transcription_boundary_is_entropy_drawn_and_matches_the_declared_content_type() { +fn transcription_boundary_is_1_5_5_s_literal_and_matches_the_declared_content_type() { let b = transcription_boundary(); - assert_ne!(b, "----busbaraudioMIME", "boundary must not be hard-coded"); - assert!(b.len() >= 32 && b.len() <= 70, "boundary length: {b}"); - assert!( - b.bytes() - .all(|c| c.is_ascii_alphanumeric() || c == b'-' || c == b'_'), - "boundary charset: {b}" - ); + assert_eq!(b, "----busbaraudioMIME"); let ct = OpenAiTranscription.egress_request_content_type(); assert_eq!(ct, format!("multipart/form-data; boundary={b}")); @@ -381,8 +366,8 @@ fn transcription_boundary_is_entropy_drawn_and_matches_the_declared_content_type assert!(text.ends_with(&format!("--{b}--\r\n")), "body tail: {text}"); } -/// Unpredictability alone is not the guarantee: a payload that DOES carry the live delimiter (a -/// guess, or a broken CSPRNG) must be refused outright rather than emitted with the parts it +/// The delimiter check is the guarantee: a payload that DOES carry the live delimiter must be +/// refused outright rather than emitted with the parts it /// smuggled. Both framings count — a delimiter mid-payload, and one at the very start (the `\r\n` /// that ends the part headers completes it). #[test] @@ -418,6 +403,7 @@ fn embeddings_taps_usage_and_extract_usage_reads_prompt_tokens() { let body = br#"{"object":"list","data":[{"object":"embedding","index":0,"embedding":[0.1,0.2]}],"usage":{"prompt_tokens":7,"total_tokens":7}}"#; let usage = OpenAiEmbeddings .extract_usage("openai", body) + .expect("the tap reads the body") .expect("token-metered embeddings yields usage"); assert_eq!(usage.input, 7); } @@ -621,10 +607,12 @@ fn multipart_single_char_boundary_parses_correctly() { assert!(r.audio.is_some()); } -// FIND-1 (money): a gpt-image-1 response carries a token `usage` object; the reader must surface it -// so `billing()` token-meters the request instead of billing nothing. Fails pre-fix (usage unset). +// A gpt-image-1 response carries a token `usage` object. Its tokens are RECORDED (residual units, +// the settle's `usage.residual` audit row) and bill NOTHING on the four token tiers, as in 1.5.5, +// whose image reader read no `usage` (v1.5.5 `crates/busbar/src/handlers/openai.rs:731-758`) and +// whose image op tapped no same-dialect body. RED while the reader token-billed 20 in / 10 out. #[test] -fn image_response_with_usage_object_bills_tokens() { +fn image_response_usage_is_recorded_never_billed() { let wire = json!({ "created": 1, "data": [{ "b64_json": "AAAA" }], @@ -633,11 +621,32 @@ fn image_response_with_usage_object_bills_tokens() { let resp = super::read_image_response(&serde_json::to_vec(&wire).unwrap()).unwrap(); match resp.billing() { Some(busbar_contract::billing::Billing::Tokens(t)) => { - assert_eq!(t.input, 20); - assert_eq!(t.output, 10); + assert_eq!( + (t.input, t.output, t.cache_read, t.cache_creation), + (0, 0, None, None), + "no image token bills: {t:?}" + ); + assert!(t.open_units.is_empty(), "no open class bills: {t:?}"); + assert_eq!( + t.residual_units, + std::collections::BTreeMap::from([ + ("image.input_tokens".to_string(), 20), + ("image.output_tokens".to_string(), 10), + ]), + "the reported tokens are recorded" + ); } - other => panic!("gpt-image-1 usage must token-bill, got {other:?}"), + other => panic!("gpt-image-1 usage is recorded on a zero token line, got {other:?}"), } + // The same-dialect tap reads the same zero bill. + let tapped = busbar_contract::codec::OperationHandler::extract_usage( + &super::OpenAiImage, + "openai", + &serde_json::to_vec(&wire).unwrap(), + ) + .expect("the tap reads the body") + .expect("the tap reads the answer"); + assert_eq!((tapped.input, tapped.output), (0, 0)); } // FIND-1 (money): a per-image (dall-e-style) response has NO usage object; the reader must record a diff --git a/crates/busbar-plane-llm/src/codec/synth_rng.rs b/crates/busbar-plane-llm/src/codec/synth_rng.rs index 6a654d05cc..ebb78bba06 100644 --- a/crates/busbar-plane-llm/src/codec/synth_rng.rs +++ b/crates/busbar-plane-llm/src/codec/synth_rng.rs @@ -1,110 +1,29 @@ -//! Thread-local ENTROPY POOL for synthesized wire ids, filled from the HOST's entropy source. +//! ENTROPY for synthesized wire ids, drawn from the HOST's entropy source on every draw. //! //! Every protocol writer that must invent a shape-correct id when the upstream omitted one //! (`anthropic` `req_…`, `bedrock`/`gemini` request ids, `cohere` v4 uuids, `responses` `resp_…`) -//! needs a fistful of random bytes on the HOT response path. Asking the host for them once PER id -//! costs an OS CSPRNG syscall per response — ~1–2 µs on macOS, and on the anthropic-ingress benchmark -//! that single syscall was the ENTIRE `rb_finish` cost (~2.5 µs p50 of a ~6.6 µs `busbar;dur`). The -//! synthesized ids are non-secret response-correlation strings returned only to the client that made -//! the request; on a single-operator proxy they never cross a trust boundary. So there is no reason -//! to pay a syscall each. +//! asks here for a fistful of random bytes. THE BYTES ARE THE HOST'S (#83a O10; the entropy arrives +//! through the host, never drawn by the plane itself): each draw goes straight to +//! `busbar_contract::codec::fill_entropy`, the host-installed source (the OS CSPRNG, armed by the host +//! where it installs the protocols). A process whose host installed no source serves nothing: the +//! draw reports failure and each caller takes its own entropy-unavailable branch. //! -//! THE BYTES ARE THE HOST'S (#83a O10; the entropy arrives through the host, never drawn by the plane -//! itself). The pool refills through `busbar_contract::codec::fill_entropy`, the host-installed -//! source (the OS CSPRNG, armed by the host where it installs the protocols), [`POOL_BYTES`] at a time, -//! and hands out slices of it. Every byte served is still a fresh host-CSPRNG byte — this is NOT a -//! userspace PRNG substituting for the host generator, it is the exact same entropy source with the -//! call AMORTISED (~one fill per `POOL_BYTES` bytes instead of per id). A process whose host installed -//! no source serves nothing: every draw reports failure and each caller takes its own -//! entropy-unavailable branch. -//! -//! Thread-local so there is no lock and no cross-thread contention on the hot path. A worker thread -//! amortises its first-request fill across the next few hundred requests it serves. -//! -//! FORK SAFETY. A userspace entropy buffer is not fork-safe: a child that inherits this thread's -//! buffer at the same read position would serve the SAME "random" bytes as the parent until its next -//! refill, so both could emit duplicate ids. This is SOUND for busbar as it exists: busbar runs a -//! tokio multi-thread runtime and never `fork()`s a worker that then synthesizes ids (subprocess -//! egress immediately `exec`s a different binary, which does not run this code). The values are -//! non-secret response-correlation strings, so even a post-fork collision is low-harm. If busbar ever -//! adds a fork-based worker model that synthesizes ids in the child, reset this pool in a -//! `pthread_atfork` child handler (or gate `fill` on a cached pid) BEFORE relying on it there. - -use std::cell::RefCell; - -/// Size of the per-thread entropy block. 4 KiB serves ~130 anthropic ids (30 bytes each, before -/// rejection-sampling waste) per host fill — a ~100× reduction in fills — while staying a -/// trivially-bounded stack/TLS cost. Large enough to matter, small enough to never notice. -const POOL_BYTES: usize = 4096; - -struct EntropyPool { - buf: [u8; POOL_BYTES], - /// Next unread byte. `pos == POOL_BYTES` means drained → refill on next draw. - pos: usize, - /// Set if the LAST refill from the host failed; callers translate this into their own - /// entropy-unavailable contract (anthropic OMITS the header, cohere zero-fills, etc.). - healthy: bool, -} - -impl EntropyPool { - fn new() -> Self { - // Start drained so the first draw triggers the initial fill (and surfaces a broken CSPRNG - // immediately, rather than serving a zeroed buffer). - EntropyPool { - buf: [0u8; POOL_BYTES], - pos: POOL_BYTES, - healthy: true, - } - } - - /// Refill the whole block from the host's entropy source. On failure the pool is marked unhealthy - /// and left drained so no stale/zeroed bytes are served. - fn refill(&mut self) { - // A test process's host is the kernel test seam, which arms the same source the composition - // root arms in a shipped binary. - #[cfg(test)] - crate::codec::ensure_test_protocols_registered(); - if busbar_contract::codec::fill_entropy(&mut self.buf) { - self.pos = 0; - self.healthy = true; - } else { - self.pos = POOL_BYTES; - self.healthy = false; - } - } - - /// Fill `out` with fresh host-CSPRNG bytes from the pool, refilling as needed. Returns `false` - /// (filling nothing meaningful) iff the host's source is unavailable. A single `out` larger than - /// the pool is filled across multiple refills. - fn fill(&mut self, out: &mut [u8]) -> bool { - let mut written = 0usize; - while written < out.len() { - if self.pos == POOL_BYTES { - self.refill(); - if !self.healthy { - return false; - } - } - let take = (out.len() - written).min(POOL_BYTES - self.pos); - out[written..written + take].copy_from_slice(&self.buf[self.pos..self.pos + take]); - self.pos += take; - written += take; - } - true - } -} - -thread_local! { - static POOL: RefCell = RefCell::new(EntropyPool::new()); -} - -/// Fill `out` with host-CSPRNG bytes from the thread-local pool: returns `true` on success, `false` -/// iff the host's entropy source is unavailable (in which case `out`'s contents are unspecified and -/// callers must honour their entropy-failure branch). Amortises the host fill across [`POOL_BYTES`] -/// bytes. +//! NO STATE OF ITS OWN. This module used to amortise the host call through a 4 KiB per-thread +//! buffer. A plugin holds no process-global state — what it keeps is per-instance, and the one +//! owner-approved per-thread slot is the door's log-capture slot (BUSBAR-1.6.0.md THE DESIGN §2) — +//! so that buffer is gone: every draw is one host call, and nothing outlives it or is shared +//! between instances on a thread (a dropped-in image carries no copy either). + +/// Fill `out` with host-CSPRNG bytes: returns `true` on success, `false` iff the host's entropy +/// source is unavailable (in which case `out`'s contents are unspecified and callers must honour +/// their entropy-failure branch). #[inline] pub fn fill_entropy(out: &mut [u8]) -> bool { - POOL.with(|p| p.borrow_mut().fill(out)) + // A test process's host is the kernel test seam, which arms the same source the composition + // root arms in a shipped binary. + #[cfg(test)] + crate::codec::ensure_test_protocols_registered(); + busbar_contract::codec::fill_entropy(out) } #[cfg(test)] diff --git a/crates/busbar-plane-llm/src/codec/tests/eventstream_tests.rs b/crates/busbar-plane-llm/src/codec/tests/eventstream_tests.rs index efb2ad68ec..b94dcd3227 100644 --- a/crates/busbar-plane-llm/src/codec/tests/eventstream_tests.rs +++ b/crates/busbar-plane-llm/src/codec/tests/eventstream_tests.rs @@ -864,74 +864,99 @@ fn a_malformed_prelude_after_valid_frames_still_clears_and_reports_the_prefix() assert!(buf.is_empty(), "the WHOLE buffer clears, prefix included"); } +/// A buffer of `n` complete small frames. +fn frames_buffer(n: usize) -> Vec { + let mut buf = Vec::new(); + for i in 0..n { + let payload = format!("{{\"i\":{i}}}"); + buf.extend_from_slice(&encode_frame("contentBlockDelta", payload.as_bytes())); + } + buf +} + +/// The drain's work grew linearly from `n` to `4n` frames: under 8× — the log-scale midpoint of +/// linear's ~4× and quadratic's ~16× — in COUNTED work units, so no scheduler, cache or neighbour +/// load can move the verdict (Law 8). +fn scales_linearly(small: DrainWork, big: DrainWork) -> bool { + big.total() < small.total() * 8 +} + +/// The drain as it was before the index-tracking rewrite: one front `drain` PER FRAME, each +/// memmoving the whole remaining tail. The deliberately quadratic reference the checker must reject. +fn per_frame_drain(buf: &mut Vec) -> DrainWork { + let mut work = DrainWork::default(); + while buf.len() >= PRELUDE_LEN { + let total_len = u32::from_be_bytes([buf[0], buf[1], buf[2], buf[3]]) as usize; + if buf.len() < total_len { + break; + } + work.frames += 1; + work.scanned += total_len; + consume_front(buf, total_len, &mut work); + } + work +} + /// Complexity regression: `drain_frames_checked` used `Vec::drain(..total_len)` PER FRAME, which -/// memmoves the entire remaining tail on every call — O(frames × bytes) for a buffer of many -/// small frames. The index-tracking rewrite does ONE buffer edit for the whole pass, O(bytes). -/// A ratio test (not an absolute wall-clock threshold) so it stays machine-independent: quadratic -/// predicts ~16× for a 4× frame-count increase; linear predicts ~4×. -/// -/// WEAKEST TEST IN THIS FILE: if `t(1000)` is too fast to be -/// above timer-granularity noise, the ratio is meaningless. The assertion checks that floor first -/// and panics with a clear message rather than silently passing on noise. +/// memmoves the entire remaining tail on every call — O(frames × bytes) for a buffer of many small +/// frames. The index-tracking rewrite does ONE buffer edit for the whole pass, O(bytes). /// -/// MIN-OF-`TRIALS`, not a single sample: contention from other tests running concurrently in the -/// same `cargo test --workspace` invocation can only ever ADD delay to one `Instant::now()` / -/// `elapsed()` pair — a scheduler preemption, a cache eviction from a neighboring thread, GC-style -/// jemalloc housekeeping — never subtract below the true uncontended cost of the drain. So the -/// MINIMUM across several independent trials converges toward that true cost regardless of how -/// loaded the machine is, while a single sample has no such guarantee and can land on an -/// arbitrarily contended instant for either data point, skewing the ratio in either -/// direction. This is a structural hardening of the MEASUREMENT, not a widened tolerance: the -/// asserted ratio, its rationale, and the floor check are all unchanged. +/// COUNTED, NOT TIMED. The previous form compared wall-clock times of 1M- and 4M-frame drains and +/// flaked under shared CI load (ModernCI quarantined it): a ratio of two timings measures the +/// machine as much as the code. The drain now counts its own buffer work ([`DrainWork`]: bytes +/// walked as frames, buffer edits, bytes those edits moved), and every buffer edit goes through the +/// counting [`consume_front`] / [`clear_all`]. So: one pass makes exactly ONE edit, moves nothing +/// when it consumes whole frames, walks every byte once, and its total work grows ~4× from n to 4n +/// frames — while the per-frame drain makes n edits, moves O(n²) bytes and fails the same check. #[test] fn drain_frames_checked_scales_linearly_in_frame_count() { - /// Independent trials per data point; the reported duration is the minimum observed. Three - /// (not more) because at 1M/4M frames each measurement is hundreds of milliseconds of - /// DRAM-bound work — scheduler blips that dominate a microsecond sample are relative - /// rounding error here, and min-of-N only needs ONE uncontended trial to converge. - const TRIALS: u32 = 3; - - fn bench(n: usize) -> std::time::Duration { - let mut best: Option = None; - for _ in 0..TRIALS { - let mut buf = Vec::new(); - for i in 0..n { - let payload = format!("{{\"i\":{i}}}"); - buf.extend_from_slice(&encode_frame("contentBlockDelta", payload.as_bytes())); - } - let start = std::time::Instant::now(); - let (_, status, _) = drain_frames_checked(&mut buf, None); - assert_eq!(status, DrainStatus::Ok); - let elapsed = start.elapsed(); - best = Some(match best { - Some(b) if b <= elapsed => b, - _ => elapsed, - }); - } - best.expect("TRIALS is a nonzero constant, so the loop runs at least once") - } - // 1M and 4M frames (~30 MB and ~120 MB of buffer): BOTH data points are far past every - // cache level, so the whole measurement lives in one memory regime (DRAM). The earlier - // 8k/32k sizing straddled the L2→L3 boundary, which made genuinely linear code measure - // 6.4–8.9x on real CI runners and forced arbitrary threshold tuning; at these sizes the - // linear prediction really is ~4x and quadratic really is ~16x, with nothing in between - // to hand-wave about. - let t1m = bench(1_000_000); + let run = |n: usize| { + let mut buf = frames_buffer(n); + let bytes = buf.len(); + let mut work = DrainWork::default(); + let (frames, status, consumed) = drain_frames_counted(&mut buf, None, &mut work); + assert_eq!(status, DrainStatus::Ok); + assert_eq!(frames.len(), n); + assert_eq!(consumed, bytes); + assert!(buf.is_empty()); + assert_eq!(work.frames, n); + assert_eq!(work.scanned, bytes, "every byte is walked once"); + assert_eq!(work.edits, 1, "one buffer edit for the whole pass"); + assert_eq!(work.shifted, 0, "consuming whole frames moves nothing"); + work + }; + let (small, big) = (run(1_000), run(4_000)); assert!( - t1m >= std::time::Duration::from_millis(1), - "min-of-{TRIALS} t(1M) = {t1m:?} is too fast to be above timer-granularity noise; \ - the ratio below would be meaningless, so this assertion is WITHDRAWN rather than \ - tuned" - ); - let t4m = bench(4_000_000); - // < 8x: the log-scale midpoint of linear's ~4x and quadratic's ~16x — equal headroom to - // both hypotheses, not a constant tuned to any particular runner's measurement. + scales_linearly(small, big), + "drain_frames_checked did {} work units for 1k frames and {} for 4k (ratio {:.1}x; linear \ + predicts ~4x, quadratic ~16x)", + small.total(), + big.total(), + big.total() as f64 / small.total() as f64 + ); + + // A trailing partial frame is kept and moved once, not once per frame. + let mut buf = frames_buffer(1_000); + buf.extend_from_slice(&frames_buffer(1)[..PRELUDE_LEN + 2]); + let mut work = DrainWork::default(); + let (_, status, _) = drain_frames_counted(&mut buf, None, &mut work); + assert_eq!(status, DrainStatus::Ok); + assert_eq!(work.edits, 1); + assert_eq!(work.shifted, PRELUDE_LEN + 2, "only the partial tail moves"); +} + +/// The RED arm: the per-frame drain this test exists to catch makes one edit per frame and moves a +/// quadratic number of bytes, and the same linearity check rejects it. +#[test] +fn the_linearity_check_rejects_a_per_frame_drain() { + let small = per_frame_drain(&mut frames_buffer(1_000)); + let big = per_frame_drain(&mut frames_buffer(4_000)); + assert_eq!((small.edits, big.edits), (1_000, 4_000)); assert!( - t4m < t1m * 8, - "drain_frames_checked scaled worse than linear: min-of-{TRIALS} t(1M)={t1m:?} \ - min-of-{TRIALS} t(4M)={t4m:?} (ratio {:.1}x, quadratic predicts ~16x, linear \ - predicts ~4x)", - t4m.as_secs_f64() / t1m.as_secs_f64() + !scales_linearly(small, big), + "a per-frame drain must fail the check: {} vs {} work units", + small.total(), + big.total() ); } diff --git a/crates/busbar-plane-llm/src/codec/tests/proto/same_proto_fidelity_tests.rs b/crates/busbar-plane-llm/src/codec/tests/proto/same_proto_fidelity_tests.rs index 319ee0c7f2..797e55aab2 100644 --- a/crates/busbar-plane-llm/src/codec/tests/proto/same_proto_fidelity_tests.rs +++ b/crates/busbar-plane-llm/src/codec/tests/proto/same_proto_fidelity_tests.rs @@ -169,7 +169,7 @@ fn same_proto_cross_chunk_split_is_verbatim() { /// suite (timing is environment-sensitive). Run with: /// cargo test --release bench_same_proto_short_circuit -- --ignored --nocapture #[test] -#[ignore] +#[ignore = "a timing bench: run with --release --ignored --nocapture"] fn bench_same_proto_short_circuit() { use crate::codec::eventstream::encode_frame; diff --git a/crates/busbar-plane-llm/src/codec/tests/synth_rng_tests.rs b/crates/busbar-plane-llm/src/codec/tests/synth_rng_tests.rs index e4807d98c8..87c7c295f2 100644 --- a/crates/busbar-plane-llm/src/codec/tests/synth_rng_tests.rs +++ b/crates/busbar-plane-llm/src/codec/tests/synth_rng_tests.rs @@ -1,13 +1,12 @@ // SPDX-License-Identifier: Apache-2.0 // Copyright (C) 2026 Busbar Inc and contributors -//! Tests for `crates/busbar-llm/src/synth_rng.rs`. +//! Tests for `crates/busbar-plane-llm/src/codec/synth_rng.rs`. use super::*; #[test] fn fills_exact_len_and_advances() { - // Two draws that together exceed a fresh pool are still fully served (spans a refill). let mut a = [0u8; 30]; let mut b = [0u8; 30]; assert!(fill_entropy(&mut a)); @@ -18,21 +17,32 @@ fn fills_exact_len_and_advances() { } #[test] -fn draw_larger_than_pool_spans_refills() { - let mut big = vec![0u8; POOL_BYTES * 2 + 7]; +fn a_large_draw_is_served_whole() { + let mut big = vec![0u8; 8199]; assert!(fill_entropy(&mut big)); assert!(big.iter().any(|&x| x != 0)); } #[test] fn many_small_draws_stay_distinct() { - // Exhaust well past one pool block to exercise the refill path under repeated small draws. let mut seen = std::collections::HashSet::new(); for _ in 0..500 { let mut t = [0u8; 24]; assert!(fill_entropy(&mut t)); seen.insert(t); } - // 500 distinct 24-byte draws — no collisions from a stuck/rewound pointer. + // 500 distinct 24-byte draws — no collisions from a stuck or rewound source. assert_eq!(seen.len(), 500); } + +/// The plugin holds no per-thread entropy state (BUSBAR-1.6.0.md THE DESIGN §2: the one approved +/// per-thread slot is the door's log-capture slot). RED while the module kept a `thread_local!` +/// pool: its source names no thread-local. +#[test] +fn the_entropy_seam_keeps_no_thread_local_state() { + let src = include_str!("../synth_rng.rs"); + assert!( + !src.contains(concat!("thread", "_local!")), + "synth_rng must not keep per-thread state" + ); +} diff --git a/crates/busbar-plane-llm/src/codec/tests/test_host.rs b/crates/busbar-plane-llm/src/codec/tests/test_host.rs index cde2df1d75..177aed23d1 100644 --- a/crates/busbar-plane-llm/src/codec/tests/test_host.rs +++ b/crates/busbar-plane-llm/src/codec/tests/test_host.rs @@ -10,36 +10,14 @@ //! - WALL CLOCK: the system clock in whole seconds, as the host reads it. //! - TRANSLATE CAP: none armed — the contract's documented 32 MiB default applies, which is the //! host's own default for an unconfigured limit. -//! - USAGE-TAP COUNT: a per-thread record of every `(protocol, reason)` fault the codec reports, -//! first-seen per thread, which [`tap_faults`] reads back. What the host does with a report (the -//! `busbar_billing_tap_decode_fail_total` series and the warn-once set) is pinned in the host's suite. +//! - USAGE-TAP FAULTS: none to arm. A tap returns its fault as data (row 155); what the kernel does +//! with it (the `busbar_billing_tap_decode_fail_total` series and the warn-once set) is pinned in +//! the kernel's suite. //! - CLASSIFICATION: [`classified`] reads the class the host's classifier places a raw upstream error //! in off the shared fixture `testing/plane-copies/upstream-classes.json`, which the host's suite //! holds its classifier to. -use busbar_contract::codec::CodecError; use busbar_contract::upstream::{CanonicalSignal, RawUpstreamError, StatusClass}; -use std::cell::RefCell; -use std::collections::BTreeMap; - -thread_local! { - /// Every usage-tap fault this thread reported, `(protocol, reason) -> count`. - static TAP_FAULTS: RefCell> = - const { RefCell::new(BTreeMap::new()) }; -} - -fn record_tap_fault(protocol: &str, reason: &'static str) -> bool { - TAP_FAULTS.with(|t| { - let mut t = t.borrow_mut(); - let n = t.entry((protocol.to_string(), reason)).or_insert(0); - *n += 1; - *n == 1 - }) -} - -fn report_decode_failure(protocol: &str, _error: &CodecError) { - record_tap_fault(protocol, "decode"); -} fn os_entropy(out: &mut [u8]) -> bool { getrandom::fill(out).is_ok() @@ -58,19 +36,9 @@ pub fn ensure_test_protocols_registered() { ARM.call_once(|| { busbar_contract::codec::install_entropy_source(os_entropy); busbar_contract::codec::install_wall_clock(system_clock); - busbar_contract::codec::install_usage_tap_fault_latch(record_tap_fault); - busbar_contract::codec::install_usage_tap_fault_reporter(report_decode_failure); }); } -/// The usage-tap faults THIS thread reported while running `f`, `(protocol, reason) -> count`. -pub fn tap_faults(f: impl FnOnce()) -> BTreeMap<(String, &'static str), u64> { - ensure_test_protocols_registered(); - TAP_FAULTS.with(|t| t.borrow_mut().clear()); - f(); - TAP_FAULTS.with(|t| t.borrow().clone()) -} - /// The class the host's classifier places `raw` in with an EMPTY operator `error_map`, read off the /// shared fixture. A raw shape the fixture does not list fails the test that produced it: a dialect /// whose error reader starts producing a new shape must add it there, where the host's suite checks diff --git a/crates/busbar-plane-llm/src/codec/tests/usage_tap_count_tests.rs b/crates/busbar-plane-llm/src/codec/tests/usage_tap_count_tests.rs index 0a8f7211ff..553d2fb2aa 100644 --- a/crates/busbar-plane-llm/src/codec/tests/usage_tap_count_tests.rs +++ b/crates/busbar-plane-llm/src/codec/tests/usage_tap_count_tests.rs @@ -1,24 +1,17 @@ // SPDX-License-Identifier: Apache-2.0 // Copyright (C) 2026 Busbar Inc and contributors -//! THE USAGE-TAP COUNT PIN (#83a SD-3, architect ruling R-USAGE): every way a same-protocol usage -//! tap can fail to read a 2xx body reports ONCE to the host's usage-tap seam, under the reason that -//! says which way — whether the tap is the chat cell's own (unknown protocol / bad JSON / a body its -//! reader refuses) or a leaf cell's default (the host's decode reporter). One report -//! is one increment of `busbar_billing_tap_decode_fail_total{protocol,reason}`: the host's suite pins -//! that step. The plane names no metrics crate and reaches no host: this suite reads the reports the -//! test host recorded. +//! THE USAGE-TAP FAULT PIN (#83a SD-3, architect ruling R-USAGE; row 155): every way a same-protocol +//! usage tap can fail to read a 2xx body is RETURNED as its fault, under the reason that says which +//! way, whether the tap is the chat cell's own (unknown protocol / bad JSON / a body its reader +//! refuses) or a leaf cell's default (a decode failure). The door carries the fault out on its +//! answer and the kernel counts it, one increment of +//! `busbar_billing_tap_decode_fail_total{protocol,reason}`: the kernel's suite pins that step. The +//! codec names no metrics crate, reaches no host and holds no latch: this suite reads what the tap +//! returns. -use busbar_contract::codec::OperationHandler; +use busbar_contract::codec::{OperationHandler, UsageTapFault}; use busbar_contract::operation::OpVerb; -use std::collections::BTreeMap; - -fn faults(pairs: &[(&str, &'static str, u64)]) -> BTreeMap<(String, &'static str), u64> { - pairs - .iter() - .map(|&(p, r, n)| ((p.to_string(), r), n)) - .collect() -} fn cell(op: OpVerb) -> &'static dyn OperationHandler { crate::codec::bedrock::DECL @@ -32,34 +25,44 @@ const UNREADABLE: &[u8] = b"[1,2,3]"; const NOT_JSON: &[u8] = b"{not json"; #[test] -fn the_chat_cells_three_fault_paths_each_count_once_under_their_reason() { +fn the_chat_cells_three_fault_paths_each_return_their_reason() { let chat = crate::codec::chat_handle::ChatOperation("bedrock"); let unknown = crate::codec::chat_handle::ChatOperation("no-such-dialect"); - let counts = crate::codec::test_host::tap_faults(|| { - assert!(chat.extract_usage("bedrock", UNREADABLE).is_none()); - assert!(chat.extract_usage("bedrock", NOT_JSON).is_none()); - assert!(chat.extract_usage("bedrock", NOT_JSON).is_none()); - assert!(unknown.extract_usage("no-such-dialect", b"{}").is_none()); - }); + let faults = [ + chat.extract_usage("bedrock", UNREADABLE), + chat.extract_usage("bedrock", NOT_JSON), + unknown.extract_usage("no-such-dialect", b"{}"), + ]; assert_eq!( - counts, - faults(&[ - ("bedrock", "decode", 1), - ("bedrock", "bad_json", 2), - ("no-such-dialect", "unknown_protocol", 1), - ]) + faults, + [ + Err(UsageTapFault::Decode), + Err(UsageTapFault::BadJson), + Err(UsageTapFault::UnknownProtocol), + ] ); + let reasons: Vec<&str> = faults + .iter() + .filter_map(|f| f.as_ref().err().map(|f| f.reason())) + .collect(); + assert_eq!(reasons, ["decode", "bad_json", "unknown_protocol"]); } #[test] -fn a_leaf_cells_default_tap_counts_a_refused_body_as_a_decode_failure() { - let counts = crate::codec::test_host::tap_faults(|| { - assert!(cell(OpVerb::EMBEDDINGS) - .extract_usage("bedrock", NOT_JSON) - .is_none()); - assert!(cell(OpVerb::IMAGE) - .extract_usage("bedrock", NOT_JSON) - .is_none()); - }); - assert_eq!(counts, faults(&[("bedrock", "decode", 2)])); +fn a_leaf_cells_default_tap_returns_a_refused_body_as_a_decode_fault() { + for op in [OpVerb::EMBEDDINGS, OpVerb::IMAGE] { + assert_eq!( + cell(op).extract_usage("bedrock", NOT_JSON), + Err(UsageTapFault::Decode), + "{op:?}" + ); + } +} + +/// A body the tap reads is no fault, whatever usage it states. +#[test] +fn a_readable_body_is_no_fault() { + let chat = crate::codec::chat_handle::ChatOperation("bedrock"); + let body = br#"{"output":{"message":{"role":"assistant","content":[{"text":"hi"}]}},"stopReason":"end_turn","usage":{"inputTokens":3,"outputTokens":2,"totalTokens":5}}"#; + assert!(chat.extract_usage("bedrock", body).is_ok()); } diff --git a/crates/busbar-plane-llm/src/codec/usage_count.rs b/crates/busbar-plane-llm/src/codec/usage_count.rs index fa208ab3e5..f9e59070e8 100644 --- a/crates/busbar-plane-llm/src/codec/usage_count.rs +++ b/crates/busbar-plane-llm/src/codec/usage_count.rs @@ -158,6 +158,31 @@ pub fn billed_count_opt( } } +/// A RECORDED count (one the ledger notes and never bills): absent or `null` is `Some(0)` (nothing +/// reported, as [`billed_count`] reads it), a readable count is `Some(n)`, and a present count this +/// build cannot read is UNKNOWN, `None`. It is never a zero, because a zero records no work for +/// work that happened (#81, OWNER rule 21). It is never a refusal either, because a count nothing +/// bills does not justify refusing an answer. The unreadable field is named on `warnings` and in a +/// warning log line, so the gap is visible where the record is read. +pub fn recorded_count( + usage: &serde_json::Value, + field: &'static str, + warnings: &mut Vec, +) -> Option { + match billed_count(usage, field) { + Ok(n) => Some(n), + Err(unreadable) => { + ::tracing::warn!( + field = unreadable.field, + spelling = %unreadable.spelling, + "a recorded usage count is unreadable: recorded as unknown, never as 0" + ); + warnings.push(unreadable.to_string()); + None + } + } +} + // ── THE USAGE TABLE WALK (#42) ─────────────────────────────────────────────────────────────── /// Where one usage count lands in the IR. diff --git a/crates/busbar-plane-llm/src/exchange/reply/mod.rs b/crates/busbar-plane-llm/src/exchange/reply/mod.rs index 931c23177e..ff6b5a54e8 100644 --- a/crates/busbar-plane-llm/src/exchange/reply/mod.rs +++ b/crates/busbar-plane-llm/src/exchange/reply/mod.rs @@ -21,7 +21,7 @@ use std::borrow::Cow; use std::collections::BTreeMap; use busbar_contract::billing::TokenUsage; -use busbar_contract::codec::OperationHandler; +use busbar_contract::codec::{OperationHandler, UsageTapFault}; use busbar_contract::protocol::HeadFields; use busbar_contract::upstream::{Disposition, StatusClass}; @@ -160,6 +160,9 @@ pub struct Piece<'a> { /// audit row per path (` from `), 1.5.5's row. Empty on a relay of the caller's /// own dialect and on every end that delivers nothing. pub dropped: Vec, + /// Why the usage tap could not read a same-dialect whole body, on the end that read it: the + /// door carries it out beside its units, and the kernel counts it (row 155). + pub tap_fault: Option, } enum State { @@ -239,6 +242,7 @@ fn answered<'a>(r: Rendered, units: Units, verdict: Verdict, fault: Option, + /// Why the usage tap could not read a same-dialect whole body (only when metering): returned + /// as data for the kernel to count, never reported from here (row 155). + pub tap_fault: Option, } /// How a relayed answer was cut: by the far end's transport, or by the stream's ceiling. @@ -361,6 +364,7 @@ impl Relay { }) == Some(crate::codec::ir::IrStopReason::Error); let mut open_billing: Option = None; + let mut tap_fault = None; let usage = if !self.meter { None } else if let Some(t) = self.translate.as_ref() { @@ -378,7 +382,9 @@ impl Relay { if self.nonstream_buf_truncated { Some(wire::unrecovered_usage(self.ingress, &buf)) } else { - self.handler.extract_usage(self.ingress, &buf).or_else(|| { + let tapped = self.handler.extract_usage(self.ingress, &buf); + tap_fault = tapped.as_ref().err().copied(); + tapped.ok().flatten().or_else(|| { match self.handler.read_response(&buf) { Ok(read) => { open_billing = read.billing(); @@ -418,6 +424,7 @@ impl Relay { .as_ref() .map(|t| t.dropped()) .unwrap_or_default(), + tap_fault, } } diff --git a/crates/busbar-plane-llm/src/plane_door.rs b/crates/busbar-plane-llm/src/plane_door.rs index 3692271252..f03d4f7779 100644 --- a/crates/busbar-plane-llm/src/plane_door.rs +++ b/crates/busbar-plane-llm/src/plane_door.rs @@ -58,15 +58,16 @@ use busbar_contract::abi::mechanism::lifecycle::{ GenIn, RefreshIn, ReleaseIn, TickIn, TickOut, ValidateIn, }; use busbar_contract::abi::mechanism::ticket::{CompletionHandle, Ticket}; +use busbar_contract::abi::plane::check::check_piece_in; use busbar_contract::abi::plane::{ ArriveIn, ArriveOut, BillableClass, DialectAuth, OnPieceIn, OnPieceOut, OpClass, OutField, PlaneDriveIn, PlaneDriveOut, PlaneOpenIn, PlaneOpenOut, PlaneRefreshOut, PlaneSnapshot, PlaneTail, ProjectIn, ProjectOut, RefusalIn, RefusalOut, ServeIn, ServeOut, UnitCount, CANCEL_ABORTED, CANCEL_OK_PARTIAL, CLAIM_EXACT, CLAIM_PROBE, EMIT_DONE, EMIT_TO_FAR_END, FROM_CALLER, FROM_FAR_END, FROM_KERNEL, INGRESS_REQUEST_RESPONSE, INGRESS_RESPONSE_STREAM, - PIECE_HAS_STATUS, PIECE_LAST, PRINCIPAL_NONE, PRINCIPAL_REQUIRED, REFUSAL_GATE, ROUTE_DIRECT, - ROUTE_POOL, SHAPE_PIECEWISE, SPAN_ABSENT, TAIL_FALLBACK, TAIL_PROBES, UNITS_FLOOR, - UNITS_REPORTED, VERDICT_HARD, VERDICT_NONE, VERDICT_OK, VERDICT_RETRY, + PIECE_FINAL, PIECE_HAS_STATUS, PIECE_LAST, PRINCIPAL_NONE, PRINCIPAL_REQUIRED, REFUSAL_GATE, + ROUTE_DIRECT, ROUTE_POOL, SHAPE_PIECEWISE, SPAN_ABSENT, TAIL_FALLBACK, TAIL_PROBES, + UNITS_FLOOR, UNITS_REPORTED, VERDICT_HARD, VERDICT_NONE, VERDICT_OK, VERDICT_RETRY, }; use busbar_contract::abi::plane::{PlaneCancelIn, PlaneCancelOut}; use busbar_contract::abi::plane::{RecordWrite, AUDIT_DEGRADED, RECORD_AUDIT}; @@ -442,6 +443,9 @@ struct Answer { verdict: u32, /// `FAULT_*`: the breaker's reading of this answer, written once, on its first window. fault: u8, + /// `TAP_FAULT_*`: why the usage tap could not read the body, beside the units; written once, + /// on the first window, for the kernel to count (row 155). + tap_fault: u8, /// The caller's reply is complete. done: bool, /// The unit's audit rows, `(action, resource)`, each written as a degraded `RECORD_AUDIT`: @@ -502,6 +506,9 @@ struct UnitState { reported: Vec, /// The caller was answered under a success status: the request's fee unit was incurred. fee: bool, + /// The kernel stated the current attempt's answer will not be failed over (`PIECE_FINAL`): a + /// retry verdict on it ends the unit like any other done answer. + final_answer: bool, } impl UnitState { @@ -520,6 +527,7 @@ impl UnitState { unreadable: false, reported: Vec::new(), fee: false, + final_answer: false, } } @@ -795,6 +803,10 @@ fn to_caller(piece: Piece<'_>) -> Answer { units: counts(&piece.units), verdict: verdict(piece.verdict), fault, + tap_fault: piece.tap_fault.map_or( + busbar_contract::abi::plane::TAP_FAULT_NONE, + busbar_contract::codec::UsageTapFault::code, + ), done: piece.done, ..Answer::default() } @@ -837,6 +849,7 @@ struct PieceIn<'a> { /// An ATTEMPT: the far-end request for the member the walk picked. fn attempt(unit: &mut UnitState, piece: &PieceIn<'_>) -> Answer { unit.member = String::from_utf8_lossy(piece.member).into_owned(); + unit.final_answer = false; unit.reply = None; unit.request = None; unit.started = piece.clock.map(|c| c.mono_ns); @@ -1044,17 +1057,21 @@ fn deliver( let more = p.sent < p.answer.bytes.len(); let to_far_end = p.answer.to_far_end; let done = p.answer.done && !more; + // A RETRY verdict the kernel may still fail over keeps the unit for its next ATTEMPT; a done + // answer of any other verdict, or one the kernel stated is final (`PIECE_FINAL`), ends it. + let retried = p.answer.verdict == VERDICT_RETRY && !unit.final_answer; let far = if to_far_end { EMIT_TO_FAR_END } else { 0 }; out.set(|o| &o.emitted, n as u64); out.set(|o| &o.more, u32::from(more)); out.set(|o| &o.verdict, p.answer.verdict); // Once per answer: a reading recorded on every window of a long answer would count it again. out.set(|o| &o.fault, std::mem::take(&mut p.answer.fault)); + out.set(|o| &o.tap_fault, std::mem::take(&mut p.answer.tap_fault)); out.set(|o| &o.flags, far | if done { EMIT_DONE } else { 0 }); if !more { unit.pending = None; } - (Outcome::Ready, done && !to_far_end) + (Outcome::Ready, done && !to_far_end && !retried) } // ── the slots ──────────────────────────────────────────────────────────────────────────────────── @@ -1260,12 +1277,20 @@ slot!( }; let given = input.get(); let key = given.unit; + // A piece whose flags the contract refuses (a cut or a final mark off the far end's own + // piece) is a FAULT before anything of the unit moves. + if check_piece_in(given.from, given.flags).is_err() { + return Outcome::Fault; + } // The unit leaves the map for the call, so its answer is computed with no lock held. let Some(mut unit) = guard(&door.units).remove(&key) else { return Outcome::Fault; }; guard(&door.tickets).insert(given.head.ticket, key); if unit.pending.is_none() { + if given.from == FROM_FAR_END && given.flags & PIECE_FINAL != 0 { + unit.final_answer = true; + } let piece = PieceIn { last: given.flags & PIECE_LAST != 0, status: (given.flags & PIECE_HAS_STATUS != 0).then_some(given.status_code), diff --git a/crates/busbar-plane-llm/tests/conformance.rs b/crates/busbar-plane-llm/tests/conformance.rs index 013f41521b..7a7a5fd4ec 100644 --- a/crates/busbar-plane-llm/tests/conformance.rs +++ b/crates/busbar-plane-llm/tests/conformance.rs @@ -23,7 +23,7 @@ use busbar_contract::abi::plane::{ self, slot, ArriveIn, ArriveOut, OnPieceIn, OnPieceOut, OutField, PlaneOpenIn, PlaneOpenOut, PlaneRefreshOut, ProjectIn, ProjectOut, RefusalIn, RefusalOut, ServeIn, ServeOut, UnitCount, EMIT_DONE, EMIT_TO_FAR_END, FROM_CALLER, FROM_FAR_END, FROM_KERNEL, PIECE_HAS_STATUS, - PIECE_LAST, PRINCIPAL_REQUIRED, REFUSAL_KERNEL, UNITS_REPORTED, + PIECE_LAST, PRINCIPAL_REQUIRED, REFUSAL_KERNEL, TAP_FAULT_BAD_JSON, UNITS_REPORTED, }; use busbar_contract::abi::sdk::capture::{CaptureHome, CaptureSlot}; use busbar_contract::abi::sdk::door::kind_op; @@ -158,7 +158,7 @@ impl Piece { .collect(); format!( "{:?} emitted={} more={} to_far_end={} done={} status={} verdict={} verb={} target={} \ - fields={fields:?} units={units:?}", + fields={fields:?} units={units:?} tap_fault={}", c.outcome, String::from_utf8_lossy(&self.reply[..o.emitted as usize]), o.more, @@ -168,6 +168,7 @@ impl Piece { o.verdict, at(&self.arena, o.verb), at(&self.arena, o.target), + o.tap_fault, ) } } @@ -483,6 +484,55 @@ fn the_linked_and_the_dropped_in_door_answer_every_op_the_same() { assert_eq!(script(&dropped(&d)), linked, "the dropped-in door"); } +/// A same-dialect 2xx body the usage tap cannot read: not JSON, and longer than one narrow window. +const UNREADABLE: &[u8] = b"{not json: the far end answered 200 with a body no reader can meter"; + +/// THE USAGE-TAP FAULT RIDES THE DOOR'S ANSWER AS DATA, ONCE (row 155): a same-dialect 2xx body the +/// tap cannot read is relayed as it came, and the answer that carries its units carries the fault +/// beside them, on ONE window of the answer however many the reply takes, so the kernel, which +/// counts each answer's fault, counts it exactly once. Linked and dropped in alike: the codec +/// reaches no host to report it. +#[test] +fn a_usage_tap_fault_rides_one_window_of_the_doors_answer() { + let d = Dispatcher::new(DispatchConfig::default()); + for p in [linked(&d), dropped(&d)] { + let mut i: PlaneOpenIn = z(); + i.open.head = in_head(); + (i.open.generation, i.open.settings) = (1, octets(SETTINGS)); + let mut o: PlaneOpenOut = z(); + o.open.head = out_head(); + assert_eq!(p.open(&mut Frame::new(i, o)).0.outcome, Outcome::Ready); + assert!(arrive(&p, 11, b"/v1/messages", CHAT).starts_with("arrive Ready")); + let mut wide = Piece::new(1024); + assert!(wide + .call(&p, 11, FROM_KERNEL, 0, b"", 1) + .contains("to_far_end=true")); + assert!(wide + .call(&p, 11, FROM_CALLER, PIECE_LAST, CHAT, 0) + .contains(" more=0 ")); + let mut narrow = Piece::new(16); + let whole = PIECE_HAS_STATUS | PIECE_LAST; + let mut windows = vec![narrow.call(&p, 11, FROM_FAR_END, whole, UNREADABLE, 0)]; + while windows.last().is_some_and(|w| w.contains(" more=1 ")) { + windows.push(narrow.call(&p, 11, FROM_FAR_END, 0, b"", 0)); + } + let all = windows.join("\n"); + assert!( + windows.len() > 1, + "the answer takes more than one window: {all}" + ); + let carried: Vec<&String> = windows + .iter() + .filter(|w| !w.ends_with(" tap_fault=0")) + .collect(); + assert_eq!(carried.len(), 1, "one window carries the fault: {all}"); + assert!( + carried[0].ends_with(&format!(" tap_fault={TAP_FAULT_BAD_JSON}")), + "the fault is the tap's own reason: {all}" + ); + } +} + /// A call capture for the hand-built table entry below: one slot per thread, as `plugin_door!` /// expands for a plugin's own image. struct TestCapture; @@ -544,3 +594,284 @@ fn red_a_door_that_stops_counting_the_far_ends_units_answers_differently() { assert_ne!(Some(red_line), far_end(&honest)); assert_ne!(red, honest); } + +/// THE DOOR'S UNITS ACROSS ATTEMPTS, driven through the one loader like the witness above. They live +/// in this file because it is the one file a plugin's tests may drive its door through the loader +/// from (kind isolation's both-ways witness rule). +/// +/// A RETRY VERDICT, AS THE DOOR SERVES IT (row-159 audit plane-llm L1#1; status row 155): a far-end +/// error the plane judges retryable (here a context-length 400, the error map's class) keeps the +/// unit for the kernel's next ATTEMPT, so the failover reaches the next member, as 1.5.5 served the +/// second member after the first failed (v1.5.5 `crates/busbar/src/ingress/tests/tests.rs` +/// `test_real_failover_serves_second_member_after_first_5xx`, and the context-length exclusion of +/// `proxy`'s failover walk). An answer the kernel states it will not fail over (`PIECE_FINAL`) ends +/// the unit whatever its verdict, so the door keeps nothing behind it: a later piece of that unit is +/// a FAULT, the unit is gone. +mod door_units { + use std::mem::zeroed; + use std::sync::Arc; + + use busbar_contract::abi::mechanism::call::{AbiStr, Blob, Field, Outcome, BLOB_OCTETS}; + use busbar_contract::abi::mechanism::lifecycle::slot as life; + use busbar_contract::abi::plane::{ + slot, ArriveIn, ArriveOut, OnPieceIn, OnPieceOut, OutField, PlaneOpenIn, PlaneOpenOut, + UnitCount, EMIT_DONE, FROM_CALLER, FROM_FAR_END, FROM_KERNEL, PIECE_FINAL, + PIECE_HAS_STATUS, PIECE_LAST, VERDICT_OK, VERDICT_RETRY, + }; + use busbar_plane_llm::plane_door; + use busbar_plugin_loader::dispatch::kinds::plane::Plane; + use busbar_plugin_loader::dispatch::{ + in_head, load_linked, out_head, Bind, DispatchConfig, Dispatcher, Frame, LinkedRow, NoSink, + Plugin, + }; + + fn z() -> T { + // SAFETY: every `in`/`out` here is plain C data; all-zero is a valid value of each. + unsafe { zeroed() } + } + + fn octets(b: &'static [u8]) -> Blob { + Blob { + ptr: b.as_ptr(), + len: b.len(), + fmt: BLOB_OCTETS, + flags: 0, + } + } + + const fn text(b: &'static [u8]) -> AbiStr { + AbiStr { + ptr: b.as_ptr(), + len: b.len(), + } + } + + const fn field(name: &'static [u8], value: &'static [u8]) -> Field { + Field { + name: text(name), + value: text(value), + } + } + + /// Two anthropic far ends in one pool; the error map reads `invalid_request_error` as context length. + const SETTINGS: &[u8] = br#"{ + "providers": { + "ant-a": { "protocol": "anthropic", "base_url": "https://a.example", + "error_map": { "invalid_request_error": "context_length" } }, + "ant-b": { "protocol": "anthropic", "base_url": "https://b.example" } + }, + "models": { "small": { "provider": "ant-a" }, "large": { "provider": "ant-b" } }, + "pools": { "p": { "members": ["small", "large"] } } + }"#; + const CHAT: &[u8] = + br#"{"model":"p","max_tokens":16,"messages":[{"role":"user","content":"hi"}]}"#; + const CALLER: &[Field] = &[ + field(b"content-type", b"application/json"), + field(b"anthropic-version", b"2023-06-01"), + ]; + const TOO_LONG: &[u8] = + br#"{"type":"error","error":{"type":"invalid_request_error","message":"prompt is too long"}}"#; + const ANSWER: &[u8] = br#"{"id":"msg_1","type":"message","role":"assistant","model":"large","content":[{"type":"text","text":"hello"}],"stop_reason":"end_turn","usage":{"input_tokens":7,"output_tokens":3}}"#; + const HEAD: &[Field] = &[field(b"content-type", b"application/json")]; + + fn door() -> (Dispatcher, Plugin) { + let d = Dispatcher::new(DispatchConfig::default()); + let row = LinkedRow::of(plane_door::door).expect("the linked door states its Statement"); + let p = load_linked( + &row, + Bind { + instance: Arc::from("the-instance"), + max_inflight_cap: 64, + sink: Arc::new(NoSink), + dispatcher: d.adopter(), + conns: busbar_plugin_loader::dispatch::ConnTable::Probe, + }, + ) + .expect("the linked door loads"); + let mut i: PlaneOpenIn = z(); + i.open.head = in_head(); + (i.open.generation, i.open.settings) = (1, octets(SETTINGS)); + let mut o: PlaneOpenOut = z(); + o.open.head = out_head(); + let (opened, _) = p.open(&mut Frame::new(i, o)); + assert_eq!(opened.outcome, Outcome::Ready, "the door opens"); + for s in [life::HYDRATE, life::START] { + let mut g = Frame::new( + busbar_contract::abi::mechanism::lifecycle::GenIn { + head: in_head(), + generation: 1, + }, + out_head(), + ); + assert_eq!(p.call(s, &mut g).outcome, Outcome::Ready); + } + (d, p) + } + + fn arrive(p: &Plugin, unit: u64) { + let mut a: Frame = Frame::new(z(), z()); + (a.input.head, a.out.head) = (in_head(), out_head()); + (a.input.unit, a.input.method, a.input.target) = + (unit, text(b"POST"), text(b"/v1/messages")); + (a.input.fields, a.input.fields_len) = (CALLER.as_ptr(), CALLER.len()); + a.input.body = octets(CHAT); + assert_eq!(p.call(slot::ARRIVE, &mut a).outcome, Outcome::Ready); + } + + /// One piece's answer: its outcome, verdict and whether it closed the reply. + #[derive(Debug, PartialEq, Eq)] + struct Answer { + outcome: Outcome, + verdict: u32, + done: bool, + emitted: String, + } + + #[allow(clippy::too_many_arguments)] + fn piece( + p: &Plugin, + unit: u64, + from: u32, + flags: u32, + status: u32, + bytes: &'static [u8], + member: &'static [u8], + attempt_no: u32, + ) -> Answer { + let (mut reply, mut units, mut fields, mut arena) = ( + vec![0_u8; 4096], + [z::(); 8], + [z::(); 16], + [0_u8; 1024], + ); + let mut i: OnPieceIn = z(); + i.head = in_head(); + (i.unit, i.from, i.flags, i.bytes) = (unit, from, flags, octets(bytes)); + (i.attempt_no, i.member, i.pool) = (attempt_no, text(member), text(b"p")); + if flags & PIECE_HAS_STATUS != 0 { + i.status_code = status; + (i.head_fields, i.head_fields_len) = (HEAD.as_ptr(), HEAD.len()); + } + (i.reply_buf, i.reply_cap) = (reply.as_mut_ptr(), reply.len()); + (i.units_buf, i.units_cap) = (units.as_mut_ptr(), units.len()); + (i.fields_buf, i.fields_cap) = (fields.as_mut_ptr(), fields.len()); + (i.arena_buf, i.arena_cap) = (arena.as_mut_ptr(), arena.len()); + let mut o: OnPieceOut = z(); + o.head = out_head(); + let mut f = Frame::new(i, o); + let called = p.call(slot::ON_PIECE, &mut f); + Answer { + outcome: called.outcome, + verdict: f.out.verdict, + done: f.out.flags & EMIT_DONE != 0, + emitted: String::from_utf8_lossy(&reply[..f.out.emitted as usize]).into_owned(), + } + } + + /// One attempt of `unit` on `member`: its ATTEMPT piece and the caller's body. + fn attempt(p: &Plugin, unit: u64, member: &'static [u8], attempt_no: u32) { + let a = piece(p, unit, FROM_KERNEL, 0, 0, b"", member, attempt_no); + assert_eq!( + a.outcome, + Outcome::Ready, + "the ATTEMPT on {member:?}: {a:?}" + ); + let b = piece(p, unit, FROM_CALLER, PIECE_LAST, 0, CHAT, member, 0); + assert_eq!(b.outcome, Outcome::Ready, "the body on {member:?}: {b:?}"); + } + + /// RED: the door forgot the unit on its retry verdict, so the second ATTEMPT was a FAULT and the + /// failover never reached `large`. + #[test] + fn a_retry_verdict_keeps_the_unit_and_the_failover_reaches_the_next_member() { + let (_d, p) = door(); + arrive(&p, 7); + attempt(&p, 7, b"small", 1); + let failed = piece( + &p, + 7, + FROM_FAR_END, + PIECE_HAS_STATUS | PIECE_LAST, + 400, + TOO_LONG, + b"small", + 0, + ); + assert_eq!(failed.outcome, Outcome::Ready, "{failed:?}"); + assert_eq!( + failed.verdict, VERDICT_RETRY, + "context length is retried: {failed:?}" + ); + attempt(&p, 7, b"large", 2); + let served = piece( + &p, + 7, + FROM_FAR_END, + PIECE_HAS_STATUS | PIECE_LAST, + 200, + ANSWER, + b"large", + 0, + ); + assert_eq!(served.outcome, Outcome::Ready, "{served:?}"); + assert_eq!(served.verdict, VERDICT_OK); + assert!( + served.done && served.emitted.contains("hello"), + "{served:?}" + ); + // The unit ended with its answer: nothing of it is left behind. + let after = piece(&p, 7, FROM_KERNEL, 0, 0, b"", b"small", 3); + assert_eq!( + after.outcome, + Outcome::Fault, + "the ended unit is gone: {after:?}" + ); + } + + /// RED: a door that keeps every retried unit leaves one behind when the kernel will not fail the + /// answer over (a relayed answer): the unit's later piece is answered instead of faulted. + #[test] + fn a_final_answer_ends_the_unit_whatever_its_verdict() { + let (_d, p) = door(); + arrive(&p, 9); + attempt(&p, 9, b"small", 1); + let last = piece( + &p, + 9, + FROM_FAR_END, + PIECE_HAS_STATUS | PIECE_LAST | PIECE_FINAL, + 400, + TOO_LONG, + b"small", + 0, + ); + assert_eq!(last.outcome, Outcome::Ready, "{last:?}"); + assert_eq!(last.verdict, VERDICT_RETRY); + assert!(last.done, "the final answer closes the reply: {last:?}"); + let after = piece(&p, 9, FROM_KERNEL, 0, 0, b"", b"large", 2); + assert_eq!( + after.outcome, + Outcome::Fault, + "nothing left behind: {after:?}" + ); + } + + /// A piece whose flags the contract refuses (`PIECE_FINAL` without a status) is a FAULT. + #[test] + fn a_final_mark_off_the_status_piece_is_a_fault() { + let (_d, p) = door(); + arrive(&p, 11); + attempt(&p, 11, b"small", 1); + let bad = piece( + &p, + 11, + FROM_FAR_END, + PIECE_LAST | PIECE_FINAL, + 0, + ANSWER, + b"small", + 0, + ); + assert_eq!(bad.outcome, Outcome::Fault, "{bad:?}"); + } +} diff --git a/crates/busbar-voice/src/bin/voice-conform.rs b/crates/busbar-voice/src/bin/voice-conform.rs index eda4d8b5cc..d33f279a09 100644 --- a/crates/busbar-voice/src/bin/voice-conform.rs +++ b/crates/busbar-voice/src/bin/voice-conform.rs @@ -1950,10 +1950,10 @@ fn probe_provider_dial() -> (&'static str, String) { let host = FreshBreakerHost::default(); let url = format!("ws://{addr}"); - let policy = busbar_kernel::net_guard::GuardPolicy { + let policy = busbar_kernel::proxy::egress_unit::trust::net::GuardPolicy { allow_private: true, allow_plaintext: true, - ..busbar_kernel::net_guard::GuardPolicy::default() + ..busbar_kernel::proxy::egress_unit::trust::net::GuardPolicy::default() }; let (mut provider_in, _provider_out) = match busbar_voice::topology::dial_provider( &host, @@ -2115,7 +2115,7 @@ fn probe_route_failover() -> (&'static str, String) { tokio_rt.block_on(async move { let host = FixtureHost::new(); let pool = "stream:conform-route-failover"; - let policy = busbar_kernel::net_guard::GuardPolicy::default(); + let policy = busbar_kernel::proxy::egress_unit::trust::net::GuardPolicy::default(); // ATTEMPT 1 — breaker CLOSED: a real dial to a target the default fail-closed guard refuses (a // plaintext `ws://` loopback address, `allow_plaintext: false` by default) genuinely fails, diff --git a/crates/busbar-voice/src/mount.rs b/crates/busbar-voice/src/mount.rs index 1a7e86addc..fefead2989 100644 --- a/crates/busbar-voice/src/mount.rs +++ b/crates/busbar-voice/src/mount.rs @@ -45,6 +45,7 @@ use crate::topology::{ StartError, }; use busbar_kernel::config::RootCfg; +use busbar_kernel::door::UnitKeyMint; use busbar_kernel::egress::{ duplex_ws::{CredentialPlacement as WsPlacement, DialCredential}, engine::{send_bounded, EngineClient}, @@ -63,7 +64,7 @@ use busbar_kernel::plane_host::{ }; use busbar_kernel::plane_host::{GauntletPlane, GauntletRequest}; use busbar_kernel::plane_routes::PlaneRouteSpec; -use busbar_kernel::{door::UnitKeyMint, net_guard::GuardPolicy}; +use busbar_kernel::proxy::egress_unit::trust::net::GuardPolicy; use bytes::Bytes; use http_body_util::{BodyExt, Full}; use std::any::Any; diff --git a/crates/busbar-voice/src/tests/breaker_tests.rs b/crates/busbar-voice/src/tests/breaker_tests.rs index f615ef934c..1157b1ca02 100644 --- a/crates/busbar-voice/src/tests/breaker_tests.rs +++ b/crates/busbar-voice/src/tests/breaker_tests.rs @@ -19,7 +19,7 @@ use crate::testkit::fixture_host::FixtureHost; use crate::topology::{dial_provider, stream_breaker_key, DialProviderError}; use busbar_contract::upstream::{CanonicalSignal, StatusClass}; -use busbar_kernel::net_guard::GuardPolicy; +use busbar_kernel::proxy::egress_unit::trust::net::GuardPolicy; use busbar_kernel::store::BreakerState; /// A canonical hard-down signal — the disposition a definitive provider failure (auth/billing, or a diff --git a/crates/busbar-voice/src/tests/hook_gate_tests.rs b/crates/busbar-voice/src/tests/hook_gate_tests.rs index da445c4c21..80086993c6 100644 --- a/crates/busbar-voice/src/tests/hook_gate_tests.rs +++ b/crates/busbar-voice/src/tests/hook_gate_tests.rs @@ -176,10 +176,10 @@ async fn ws_accept_handshake(host: Arc) -> Result<(), ()> { tokio::spawn(async move { axum::serve(listener, app).await.unwrap(); }); - let policy = busbar_kernel::net_guard::GuardPolicy { + let policy = busbar_kernel::proxy::egress_unit::trust::net::GuardPolicy { allow_private: true, allow_plaintext: true, - ..busbar_kernel::net_guard::GuardPolicy::default() + ..busbar_kernel::proxy::egress_unit::trust::net::GuardPolicy::default() }; match busbar_kernel::egress::duplex_ws::dial(&format!("ws://{addr}/telephony/call-ws"), policy) .await diff --git a/crates/busbar-voice/src/topology/mod.rs b/crates/busbar-voice/src/topology/mod.rs index cba4e24620..97f960b1ac 100644 --- a/crates/busbar-voice/src/topology/mod.rs +++ b/crates/busbar-voice/src/topology/mod.rs @@ -34,12 +34,12 @@ use crate::runtime::VoiceRuntime; use busbar_contract::transport::transport::{Transport, UpstreamWireKind}; use busbar_contract::upstream::{CanonicalSignal, StatusClass}; use busbar_kernel::egress::duplex_ws::{self, DialCredential, DialError, DialTarget}; -use busbar_kernel::net_guard::GuardPolicy; use busbar_kernel::plane::handle_engine::HandleEngineError; use busbar_kernel::plane_host::{ breaker, run_gauntlet_session, BreakerHost, DispatchScope, GauntletPlane, GauntletRequest, VerifyOutcome, }; +use busbar_kernel::proxy::egress_unit::trust::net::GuardPolicy; use futures::{Sink, Stream}; use std::sync::Arc; diff --git a/crates/busbar-voice/src/topology/tests/mod.rs b/crates/busbar-voice/src/topology/tests/mod.rs index 77426095cb..9689354a4a 100644 --- a/crates/busbar-voice/src/topology/tests/mod.rs +++ b/crates/busbar-voice/src/topology/tests/mod.rs @@ -28,7 +28,7 @@ use busbar_kernel::ingress::byte_duplex::{CallRef, DuplexHandle, DuplexPlane}; #[cfg(feature = "test-support")] use busbar_kernel::ingress::duplex_ws as ws_ingress; #[cfg(feature = "test-support")] -use busbar_kernel::net_guard::GuardPolicy; +use busbar_kernel::proxy::egress_unit::trust::net::GuardPolicy; #[cfg(feature = "test-support")] use futures::SinkExt; diff --git a/crates/busbar/src/main.rs b/crates/busbar/src/main.rs index 0385d60dac..16848f9c85 100644 --- a/crates/busbar/src/main.rs +++ b/crates/busbar/src/main.rs @@ -1332,7 +1332,8 @@ async fn run(data_workers: usize, late_services: std::sync::Arc Result, String> { /// build commits: every config commit is raised to it there. The same capability carries the one /// TLS wrap every kernel outbound connection is secured with — the connector's /// (`busbar_core_connector::tls::engine::Layer`; THE DESIGN: TLS stays in the connector, and the -/// kernel names no TLS library). +/// kernel names no TLS library), and the one name lookup every kernel resolve-then-pin asks +/// (`busbar_core_connector::hostio::SystemNames`; row 130 E2: the kernel resolves no name itself). pub fn install_egress_trust(dest: Arc) { install_egress_trust_host(Arc::new(GuardedEgressTrust( dest, Arc::new(busbar_core_connector::tls::engine::Layer), + Arc::new(busbar_core_connector::hostio::SystemNames), ))); } diff --git a/crates/busbar/src/root/door_steps.rs b/crates/busbar/src/root/door_steps.rs index 3a7492cba9..22a2e1677a 100644 --- a/crates/busbar/src/root/door_steps.rs +++ b/crates/busbar/src/root/door_steps.rs @@ -459,7 +459,8 @@ pub struct DoorFacts { pub keeps: Vec, /// The plane's dialect names, by the index `arrive` answers, and each provider's dialect: an /// attempt whose far end speaks another dialect than the caller's is counted as a translation - /// (`busbar_translations_total`), as the previous release counted it. Empty = none counted. + /// (`busbar_translations_total`), as the previous release counted it, and a usage-tap fault + /// an answer carries is counted under the unit's dialect. Empty = none counted. pub translations: (Arc<[String]>, Arc>), /// The plane's declared breaker fact (`declares.breaker`, ARCHITECT Q4): whether a transient /// failure below the trip threshold benches a member's cell. `None`: it declares none, and @@ -499,6 +500,9 @@ struct DoorUnit { recorded: Option, /// A `ROUTE_SCOPE` unit several entries reach: those entries, named in its refusal's words. reachable: Vec, + /// The pool a budget `on_exhaust: downgrade` re-admitted the unit through, where one did: its + /// charge landed there and its walk routes there. + downgraded: Option, } /// ONE UNIT'S KERNEL STEPS (see the module doc), lent to the plane driver for the unit's life. @@ -638,6 +642,46 @@ impl<'s> DoorSteps<'s> { self.lock().charged } + /// The pool a budget `on_exhaust: downgrade` re-admitted the unit through, where one did: the + /// pool its charge landed on, its walk routes over and its request is counted under (1.5.5's + /// `ingress::dispatch`: "dispatch through the pool the charge actually landed on"). + #[must_use] + pub fn downgraded(&self) -> Option { + self.lock().downgraded.clone() + } + + /// WHETHER A BUDGET DOWNGRADE MAY SEND THE UNIT TO `to` (1.5.5's `admit_check` guard, hop by + /// hop): `to` is a pool of this section not yet visited, the visited set is smaller than the + /// pool count, and the caller's grant reaches `to` and every pool its `on_exhausted` fallback + /// chain spills into (a downgrade never routes a key into a pool it may not use). + fn downgrade_reaches(&self, key: &VirtualKey, to: &str, visited: &[String]) -> bool { + let pools = self.pools.pools(); + if visited.iter().any(|v| v == to) + || visited.len() >= pools.len() + || !pools.contains_key(to) + { + return false; + } + let mut seen: Vec<&str> = Vec::new(); + let mut at = Some(to); + while let Some(pool) = at { + if seen.contains(&pool) { + break; + } + if !self.pools.granted( + self.facts.scope_kind.as_deref(), + Some(key), + ROUTE_POOL, + Some(pool.as_bytes()), + ) { + return false; + } + seen.push(pool); + at = self.pools.fallback(pool); + } + true + } + /// The pool a unit's charge and refund land on: the plane's own (#47 per-plane fees), the /// route's label qualified by the plane's key (a direct route's label is 1.5.5's empty one). fn charged_pool(&self, label: &str) -> String { @@ -668,26 +712,51 @@ impl<'s> DoorSteps<'s> { let u = self.lock(); (u.routed.clone(), u.expected.clone()) }; - let label = routed.as_ref().map_or("", |(label, _)| label.as_str()); - let pool = self.charged_pool(label); - let lanes: Vec = routed - .iter() - .flat_map(|(_, members)| members) - .map(|m| plane_lane(&self.facts.plane, m)) - .collect(); - let models: Vec<&str> = lanes.iter().map(String::as_str).collect(); let units = self.expected_units(&expected); - let grant = gov - .try_admit_estimated(&self.app.cost, key, &pool, self.arrived, &models, &units) - .map_err(|blocked| { - // The plane serving the `pools` map answers a blocked admission in the previous - // release's words (`busbar_kernel::ingress::limit_refusal`). - if self.facts.plane.is_empty() { - self.lock().refused.1 = - Some(busbar_kernel::ingress::limit_refusal("", &blocked).2); + let lanes_of = |routed: &Option| -> Vec { + routed + .iter() + .flat_map(|(_, members)| members) + .map(|m| plane_lane(&self.facts.plane, m)) + .collect() + }; + // BUDGET DOWNGRADE (1.5.5 `ingress::admit_check`): a budget block whose limit declared + // `on_exhaust: downgrade` re-admits through its `downgrade_to` pool instead of refusing. The + // chain may cascade; the visited set bounds it, and every hop re-judges the caller's grant. + // The charge lands on the EFFECTIVE pool and the unit's walk routes there. + let mut routed = routed; + let mut visited: Vec = Vec::new(); + let (grant, pool, lanes) = loop { + let label = routed.as_ref().map_or("", |(label, _)| label.as_str()); + let pool = self.charged_pool(label); + let lanes = lanes_of(&routed); + let models: Vec<&str> = lanes.iter().map(String::as_str).collect(); + match gov.try_admit_estimated(&self.app.cost, key, &pool, self.arrived, &models, &units) + { + Ok(grant) => break (grant, pool, lanes), + Err(LimitBlocked::Limit { + downgrade_to: Some(to), + .. + }) if self.downgrade_reaches(key, &to, &visited) => { + routed = self.pools.resolve(ROUTE_POOL, Some(to.as_bytes())); + visited.push(to); } - refusal_for(&blocked) - })?; + Err(blocked) => { + // The plane serving the `pools` map answers a blocked admission in the + // previous release's words (`busbar_kernel::ingress::limit_refusal`). + if self.facts.plane.is_empty() { + self.lock().refused.1 = + Some(busbar_kernel::ingress::limit_refusal("", &blocked).2); + } + return Err(refusal_for(&blocked)); + } + } + }; + if let Some(to) = visited.last() { + let mut u = self.lock(); + u.routed = routed; + u.downgraded = Some(to.clone()); + } money.open( ctx.key, UnitMoney { @@ -761,6 +830,13 @@ impl DriverSteps for DoorSteps<'_> { } } + fn usage_tap_fault(&self, _ctx: &UnitCtx, dialect: u32, reason: &'static str) { + // The unit's dialect is the ingress protocol its tap read the body as (row 155). + if let Some(protocol) = self.facts.translations.0.get(dialect as usize) { + busbar_kernel::handlers::usage_tap_fault(protocol, reason); + } + } + fn refusal_words(&self, reason: ReasonCode) -> Option> { // A unit routed by scope that no one entry reached: the entries that reached it (none, or // several), so the plane words which (abi/plane `ROUTE_SCOPE`). diff --git a/crates/busbar/src/root/linked.rs b/crates/busbar/src/root/linked.rs index d16f9808b9..0a11788b11 100644 --- a/crates/busbar/src/root/linked.rs +++ b/crates/busbar/src/root/linked.rs @@ -1502,3 +1502,7 @@ mod linked_protocols; #[cfg(all(test, linked_axis_hooks))] #[path = "tests/linked_hook_ranking.rs"] mod linked_hook_ranking; + +#[cfg(test)] +#[path = "tests/linked_store_double.rs"] +mod linked_store_double; diff --git a/crates/busbar/src/root/mod.rs b/crates/busbar/src/root/mod.rs index bec5e5fd8c..985b8fbd06 100644 --- a/crates/busbar/src/root/mod.rs +++ b/crates/busbar/src/root/mod.rs @@ -96,6 +96,7 @@ pub mod plane_node; pub mod policy; pub mod registry; pub mod serve; +pub mod shutdown; pub mod transports; #[cfg(feature = "root-admin")] pub mod units_admin; diff --git a/crates/busbar/src/root/serve.rs b/crates/busbar/src/root/serve.rs index 9c18e4da54..f8461f20b8 100644 --- a/crates/busbar/src/root/serve.rs +++ b/crates/busbar/src/root/serve.rs @@ -3156,8 +3156,13 @@ impl DataRoutes { .instrument(span.clone()) .await }; + // A budget downgrade re-pooled the unit: its span, like its request count below, names the + // pool the charge landed on (1.5.5 `ingress::dispatch`). + let downgraded = steps.downgraded(); if let Some(decoded) = units.decoded() { - if let Some(pool) = decoded.pool.as_ref() { + if let Some(pool) = downgraded.as_deref() { + span.record("pool", pool); + } else if let Some(pool) = decoded.pool.as_ref() { span.record("pool", String::from_utf8_lossy(pool).as_ref()); } if let Some(dialect) = served.dialects.get(decoded.dialect as usize) { @@ -3197,9 +3202,12 @@ impl DataRoutes { }, |d| d.dialect, ); - let pool = decoded - .and_then(|d| d.pool) - .map(|p| String::from_utf8_lossy(&p).into_owned()) + let pool = downgraded + .or_else(|| { + decoded + .and_then(|d| d.pool) + .map(|p| String::from_utf8_lossy(&p).into_owned()) + }) .filter(|name| { live.pools.pools().contains_key(name) || live.pools.entries().iter().any(|e| e == name) diff --git a/crates/busbar/src/root/shutdown.rs b/crates/busbar/src/root/shutdown.rs new file mode 100644 index 0000000000..5c8d61f915 --- /dev/null +++ b/crates/busbar/src/root/shutdown.rs @@ -0,0 +1,70 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (C) 2026 Busbar Inc and contributors + +//! THE ONE SHUTDOWN TAIL. Every way the process stops serving ends here, once, before it exits: +//! the listener path after its graceful drain, and the line-serve mode (busbar as a child process +//! speaking on stdin/stdout) at stdin EOF. What the caller was already answered for has to reach +//! the store before the process is gone, whichever transport the answer went out on (ruling H2 +//! U10; BUSBAR-1.6.0.md THE DESIGN, §7): +//! +//! 1. the budget and metering write-behind, flushed synchronously; +//! 2. the plane record write-behind, drained ([`crate::root::serve::drain_records`]); +//! 3. the journal's lane to the configured store, drained under the same deadline +//! ([`crate::root::serve::RECORD_DRAIN`]): on a node with no data directory the store is where +//! the chain is kept, so a record still queued at exit is a settled unit the store never saw. +//! +//! Two tails were two lists, and the line-serve one had lost steps 2 and 3 (audit busbar-src #1). +//! One function is one list. + +use std::sync::{Arc, Mutex}; + +use busbar_kernel::governance::GovState; + +use crate::root::durability::Durability; +use crate::root::serve::{drain_records, LateServices, RECORD_DRAIN}; + +/// Run the shutdown tail: flush `governance`'s write-behind, drain the plane records `late` +/// queued, then drain the journal lane of the book `journal` holds (a book over the configured +/// store has one; none, or a book with no lane, has nothing queued). Returns once each has drained +/// or its deadline passed; a step that did not drain is logged, never fatal. +pub async fn tail( + governance: Option>, + late: &LateServices, + journal: Option<&Arc>>, +) { + // BUDGET WRITE-BEHIND: one FINAL, SYNCHRONOUS flush after the graceful drain, so a graceful stop + // persists the freshest accrued spend/requests before the process exits. The background + // flusher's shutdown arm also flushes, but it is a fire-and-forget task that could lose the race + // with process exit; flushing inline here guarantees durability (this call blocks briefly under + // the budget lock, off any request path — the serving side has already stopped). + if let Some(gov) = governance { + let n = gov.flush_budgets(); + tracing::info!(flushed = n, "budget counters flushed on shutdown"); + // The flusher task's own shutdown arm also flushes metering, but it is fire-and-forget and + // can lose the race with process exit (same reason the budget flush above is inline here). + let m = gov.flush_metering(); + tracing::info!(flushed = m, "metering rows flushed on shutdown"); + } + // THE PLANE RECORD WRITE-BEHIND, drained before the store closes. + if !drain_records(late).await { + tracing::warn!("plane record writes were still queued at shutdown"); + } + // THE JOURNAL'S LANE TO THE STORE, drained the same way and under the same deadline. + if let Some(lane) = journal.and_then(|book| { + book.lock() + .unwrap_or_else(|p| p.into_inner()) + .lane() + .cloned() + }) { + let drained = tokio::task::spawn_blocking(move || lane.drain(RECORD_DRAIN)) + .await + .unwrap_or(false); + if !drained { + tracing::warn!("journal records were still queued for the store at shutdown"); + } + } +} + +#[cfg(test)] +#[path = "tests/shutdown.rs"] +mod tests; diff --git a/crates/busbar/src/root/tests/door_steps.rs b/crates/busbar/src/root/tests/door_steps.rs index 675d3401ad..af6fd9f026 100644 --- a/crates/busbar/src/root/tests/door_steps.rs +++ b/crates/busbar/src/root/tests/door_steps.rs @@ -355,6 +355,224 @@ fn a_units_principal_is_recorded_from_authenticate_until_its_steps_drop() { assert!(records.get(8).is_none(), "a refused unit writes no record"); } +// ── budget downgrade (`on_exhaust: downgrade`, 1.5.5 `ingress::admit_check`) ────────────────────── + +/// A budget limit on `pool` that downgrades to `to` once `amount` minor units are spent in a day. +fn downgrading(pool: &str, amount: u64, to: &str) -> busbar_kernel::config::groups::LimitCfg { + use busbar_kernel::config::groups::{LimitCfg, LimitMetric, LimitWindow, OnExhaust}; + LimitCfg { + metric: LimitMetric::Budget, + amount, + per: Some(LimitWindow::Day), + scope: Some(busbar_contract::records::ScopeRef::pool(pool)), + on_exhaust: Some(OnExhaust::Downgrade), + downgrade_to: Some(busbar_contract::records::ScopeRef::pool(to)), + admission: None, + on_exhaustion: None, + } +} + +/// A governed generation whose group `team` holds `limits` at a flat 10-minor-unit fee per request, +/// and a key in that group granted `allowed_pools` (`None`: every pool). +fn downgrade_book( + limits: Vec, + allowed_pools: Option>, +) -> ( + std::sync::Arc, + std::sync::Arc, +) { + use busbar_kernel::test_support::engine_kit::{EngineTestKit as _, CORE_ENGINE_KIT}; + let gov = CORE_ENGINE_KIT + .governance(CORE_ENGINE_KIT.scratch_store(), None, None) + .expect("governance"); + let groups = std::collections::BTreeMap::from([( + "team".to_string(), + busbar_kernel::config::groups::GroupCfg { + parent: None, + enabled: true, + limits, + ..Default::default() + }, + )]); + let cost = CORE_ENGINE_KIT.cost_parts(None, 10, &groups); + let (key, _secret) = gov + .create_key( + busbar_kernel::governance::NewKeySpec { + name: "dev".to_string(), + allowed_pools, + group: Some("team".to_string()), + ..Default::default() + }, + 1_700_000_000, + ) + .expect("a key"); + let app = busbar_kernel::test_support::TestApp::new() + .governance_kit(gov) + .keys_chain() + .cost_kit(cost) + .build(); + (app, std::sync::Arc::new(key)) +} + +/// What one keyed unit of the plane serving the `pools` map did at its `$` door, routed to `pool`: +/// its admission (`Ok(())`, or the refusal's reason and words), the route its walk takes, and the +/// pool a downgrade sent it to. +struct Charged { + admitted: Result<(), (busbar_contract::caps::ReasonCode, Option)>, + routed: Option<(String, Vec)>, + downgraded: Option, +} + +fn charge_once( + app: &std::sync::Arc, + key: &std::sync::Arc, + pools: &DoorPools, + pool: &str, + unit: u64, +) -> Charged { + use busbar_kernel::plane_driver::{DriverSteps as _, EndPost, PlaneMoney}; + let facts = super::door_facts("", &["pool"], &[], &[], "door", Vec::new()); + let gov = std::sync::Arc::clone(app.governance.as_ref().expect("a governed generation")); + let post = std::sync::Arc::new(crate::root::plane_node::NodeEndPost::new( + std::sync::Arc::new(crate::root::plane_node::Node::new()), + )); + let money = PlaneMoney::new(gov, post as std::sync::Arc); + let steps = super::DoorSteps::new( + &facts, + pools, + std::sync::Arc::new(|_: &str| None), + std::sync::Arc::clone(app), + Some(&money), + super::DoorCaller { + principal: busbar_contract::caps::PrincipalId::new(key.id.as_str()), + key: Some(std::sync::Arc::clone(key)), + open: false, + arrived: busbar_kernel::store::now(), + records: None, + depth: 0, + credential: None, + }, + ); + steps.lock().routed = pools.resolve(ROUTE_POOL, Some(pool.as_bytes())); + let ctx = busbar_kernel::teller::UnitCtx { + key: busbar_contract::UnitKey::new(unit), + origin: busbar_contract::caps::OriginKind::Client, + session: None, + generation: busbar_kernel::registry::Generation::FIRST, + admin_listener: false, + kernel_verb_only: false, + }; + let admitted = steps + .charge(&ctx, key) + .map(|_| ()) + .map_err(|refusal| (refusal.reason(), steps.refused_words().1)); + Charged { + admitted, + routed: steps.routed(), + downgraded: steps.downgraded(), + } +} + +const DOWNGRADE_SECTION: &str = "models:\n big: {}\n small: {}\n tiny: {}\n\ + pools:\n frontier:\n members: [big]\n value:\n members: [small]\n\ + \x20 first:\n members: [big]\n second:\n members: [small]\n third:\n members: [tiny]"; + +/// BUDGET DOWNGRADE ON THE DOOR PATH (1.5.5 `test_budget_exhaustion_downgrades_pool`): exhausting +/// the `frontier` budget re-admits the unit through `value` instead of refusing it; the charge lands +/// there and the unit's walk routes over `value`'s members. RED: a door that refuses a budget block +/// carrying `downgrade_to` answers the third unit `OverBudget` and never re-pools it. +#[test] +fn an_exhausted_budget_re_pools_the_unit_through_its_downgrade_pool() { + let (app, key) = downgrade_book(vec![downgrading("frontier", 25, "value")], None); + let pools = pools(DOWNGRADE_SECTION); + // fee 10, cap 25: two frontier admissions spend 20; the third would reach 30. + for unit in 1..=2 { + let charged = charge_once(&app, &key, &pools, "frontier", unit); + assert_eq!( + charged.admitted, + Ok(()), + "unit {unit} is under the frontier cap" + ); + assert_eq!(charged.downgraded, None, "no downgrade while under the cap"); + assert_eq!(charged.routed, owned("frontier", &["big"])); + } + let charged = charge_once(&app, &key, &pools, "frontier", 3); + assert_eq!(charged.admitted, Ok(()), "downgraded, not refused"); + assert_eq!( + charged.downgraded.as_deref(), + Some("value"), + "the charge landed on the downgrade pool" + ); + assert_eq!( + charged.routed, + owned("value", &["small"]), + "the walk follows the charge to the downgrade pool" + ); +} + +/// A DOWNGRADE CYCLE TERMINATES (1.5.5 `test_downgrade_cycle_terminates_via_the_revisit_guard`): +/// `first` -> `second` -> `third` -> `second` walks to `third` and stops at the revisit, refusing +/// with `third`'s bucket named in the previous release's words; a guard that refused one hop early +/// names `second`. +#[test] +fn a_downgrade_cycle_ends_in_the_plain_refusal_naming_the_last_pool_walked() { + let (app, key) = downgrade_book( + vec![ + downgrading("first", 10, "second"), + downgrading("second", 0, "third"), + downgrading("third", 0, "second"), + ], + None, + ); + let pools = pools(DOWNGRADE_SECTION); + let first = charge_once(&app, &key, &pools, "first", 1); + assert_eq!(first.admitted, Ok(()), "under the first pool's cap"); + assert_eq!(first.downgraded, None); + let second = charge_once(&app, &key, &pools, "first", 2); + let (reason, words) = second + .admitted + .expect_err("a cyclic downgrade chain ends in a refusal"); + assert_eq!(reason, busbar_contract::caps::ReasonCode::OverBudget); + let words = words.expect("the plane serving the `pools` map renders 1.5.5's words"); + assert!( + words.contains("pool 'third'"), + "the chain walked first -> second -> third before the revisit guard: {words}" + ); + assert_eq!( + second.downgraded, None, + "a refused unit is re-pooled nowhere" + ); +} + +/// A DOWNGRADE NEVER BYPASSES THE KEY'S POOL GRANT (1.5.5 `test_downgrade_never_bypasses_pool_acl`): +/// a key granted only `frontier` is refused the plain budget refusal, never routed into `value`. +#[test] +fn a_downgrade_never_routes_a_key_into_a_pool_it_may_not_use() { + let (app, key) = downgrade_book( + vec![downgrading("frontier", 25, "value")], + Some(vec!["frontier".to_string()]), + ); + let pools = pools(DOWNGRADE_SECTION); + for unit in 1..=2 { + assert_eq!( + charge_once(&app, &key, &pools, "frontier", unit).admitted, + Ok(()) + ); + } + let charged = charge_once(&app, &key, &pools, "frontier", 3); + assert_eq!( + charged.admitted.map_err(|(reason, _)| reason), + Err(busbar_contract::caps::ReasonCode::OverBudget), + "the plain budget refusal, not a leaked pool" + ); + assert_eq!(charged.downgraded, None); + assert_eq!( + charged.routed, + owned("frontier", &["big"]), + "the route is unchanged" + ); +} + // ── the members' routes (THE DESIGN §6 steps 2-3) ────────────────────────────────────────────── /// The decisions-shaped plane facts: one dialect `d` whose default style is `bearer`, one outbound diff --git a/crates/busbar/src/root/tests/durability_amend.rs b/crates/busbar/src/root/tests/durability_amend.rs index df9db2750c..b353aeb084 100644 --- a/crates/busbar/src/root/tests/durability_amend.rs +++ b/crates/busbar/src/root/tests/durability_amend.rs @@ -70,12 +70,14 @@ fn boot(dir: &std::path::Path) -> Arc> { book } -/// ONE HALF OF THE RESTART, run only in a child process the restart test below starts; a no-op -/// anywhere else. +/// ONE HALF OF THE RESTART, run only in a child process the restart tests below start +/// ([`run_phase`], `--ignored`). Ignored so the default run never counts it as a pass: alone it +/// proves nothing, and run without its phase it fails (status row 114). #[test] +#[ignore = "child process of the restart tests: run_phase runs it with --ignored"] fn restart_phase() { let (Ok(phase), Ok(dir)) = (std::env::var(PHASE), std::env::var(DIR)) else { - return; + panic!("restart_phase is the child half of the restart tests: run them, not this"); }; let book = boot(std::path::Path::new(&dir)); let recorded = counts(1_000); @@ -208,7 +210,7 @@ fn restart_phase() { fn run_phase(phase: &str, dir: &std::path::Path) { let out = std::process::Command::new(std::env::current_exe().expect("the test binary")) .args(["--exact", "root::durability::amend::tests::restart_phase"]) - .args(["--test-threads", "1", "--nocapture"]) + .args(["--ignored", "--test-threads", "1", "--nocapture"]) .env(PHASE, phase) .env(DIR, dir) .output() diff --git a/crates/busbar/src/root/tests/linked.rs b/crates/busbar/src/root/tests/linked.rs index 70c62fa088..56382befbb 100644 --- a/crates/busbar/src/root/tests/linked.rs +++ b/crates/busbar/src/root/tests/linked.rs @@ -550,16 +550,24 @@ fn every_linked_section_row_is_a_linked_doors_declaring_section() { /// every family type the recorder writes (a HELP-less counter, labels with escapes, a histogram, a /// quantile summary), the sink answers exactly those bytes. Moved here from busbar-kernel's /// `export/tests/scrape_tests.rs` (R-FIX3: the kernel's own tests run on the stand-in axis's scrape -/// double). A build that links no scrape sink has no sink to prove. +/// double). Compiled only where an export door is linked (`linked_axis_export_doors`): a build without one +/// has no sink to prove, and reports no vacuous pass; where it is linked, an absent module word or +/// an unresolved row is a failure. +#[cfg(linked_axis_export_doors)] #[test] fn the_linked_scrape_sink_renders_the_recorder_snapshot_byte_identically() { let module = busbar_kernel::test_support::export_axis::scrape_module(); let registry = PluginRegistry::empty() .link(linked_exports(crate::LINKED.export_doors).expect("the linked export rows")) .expect("the linked rows alone"); - if module.is_empty() || registry.resolve(module).is_none() { - return; - } + assert!( + !module.is_empty(), + "the build links the scrape sink, so the frozen scrape module word is set" + ); + assert!( + registry.resolve(module).is_some(), + "the linked export rows carry the scrape sink under `{module}`" + ); let sink = crate::root::loader::export_axis::ExportRows::new( ®istry, crate::root::dispatch::dispatcher(), diff --git a/crates/busbar/src/root/tests/linked_store_double.rs b/crates/busbar/src/root/tests/linked_store_double.rs new file mode 100644 index 0000000000..838070eece --- /dev/null +++ b/crates/busbar/src/root/tests/linked_store_double.rs @@ -0,0 +1,247 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (C) 2026 Busbar Inc and contributors + +//! THE LINKED STORE DOOR, PROVEN WHERE IT IS LINKED (R-FIX3). The kernel's test build takes no store +//! crate under any name (THE DESIGN Part 2 #2: the kernel reaches a store only through the store +//! kind's door): it links its in-crate double (`busbar_kernel::test_support::store_double`), and +//! its own tests run against that. These prove the double copies the real door the root links +//! (`crate::LINKED.stores`): the same Statement, and the same answers to one verb script, both +//! stores opened through their doors on the store axis exactly as boot opens the configured store. + +use std::sync::Arc; + +use busbar_contract::abi::mechanism::door::DoorFn; +use busbar_contract::abi::mechanism::rendering; +use busbar_contract::records::{ + AuditRecord, PlaneDisposition, PlaneRecord, PlaneSelector, RecordStore, UsageDelta, VirtualKey, +}; +use busbar_contract::store_calls::{StoreAxis as _, StoreDoor}; +use busbar_kernel::test_support::store_double; + +fn read(door: DoorFn) -> rendering::Read { + let stated = crate::root::loader::dispatch::rendering_of(door).expect("the door states"); + rendering::read(&stated).expect("the rendering reads") +} + +/// The root's linked store row the double stands in for: the one under the double's key. +fn real_row() -> &'static busbar_kernel::preflight::LinkedStore { + crate::LINKED + .stores + .iter() + .find(|s| s.0 == store_double::STORE.0) + .expect("the root links a store under the key the double answers") +} + +/// `door`, opened through the store axis on a dispatcher of its own, as boot opens a store. +fn open(door: DoorFn, label: &str) -> Arc { + let axis = crate::root::loader::store_v3::DoorStoreAxis { + dispatcher: Arc::new(crate::root::loader::dispatch::Dispatcher::new( + crate::root::loader::dispatch::DispatchConfig::default(), + )), + logs: crate::root::boot::plugin_logs().clone(), + // Neither store declares a need: each serves with no connection table. + conns: crate::root::loader::dispatch::ConnTable::NoNeeds, + mint: busbar_kernel::door::op_id, + }; + axis.open(StoreDoor::Linked(door), label, b"{}") + .expect("the store opens through its door") + .records +} + +fn audit(seq: u64, hash: &str) -> AuditRecord { + AuditRecord { + seq, + ts: 5, + action: "key.create".into(), + resource: "key:k1".into(), + outcome: "applied".into(), + principal: "root".into(), + prev_hash: String::new(), + hash: hash.into(), + } +} + +fn plane(kind: &str, id: &str, parent: Option<&str>, seq: u64) -> PlaneRecord { + PlaneRecord { + kind: kind.into(), + id: id.into(), + parent: parent.map(str::to_string), + seq, + ts: 5, + disposition: PlaneDisposition::Active, + body: format!("{id}@{seq}").into_bytes(), + } +} + +/// One verb script, every answer written down: keys (the tombstone and scrub refusals), usage, the +/// audit chain (its fork refusal), plane records (upsert, appended chain, parent listing), the +/// spent-token test-and-set, the live-token read and the denylist. No answer reads the wall clock. +fn transcript(s: &dyn RecordStore) -> Vec { + let mut t = Vec::new(); + let key = VirtualKey { + id: "k1".into(), + name: "one".into(), + enabled: true, + created_at: 10, + ..Default::default() + }; + t.push(format!("put_key {:?}", s.put_key(&key).map_err(|e| e.0))); + t.push(format!( + "put_key again {:?}", + s.put_key(&key).map_err(|e| e.0) + )); + let got = s + .get_key("k1") + .map(|k| k.map(|k| (k.id, k.name, k.enabled, k.revision, k.deleted_at))); + t.push(format!("get_key {:?}", got.map_err(|e| e.0))); + let since = s.list_keys_since(0).map(|v| { + v.into_iter() + .map(|k| (k.id, k.revision)) + .collect::>() + }); + t.push(format!("list_keys_since {:?}", since.map_err(|e| e.0))); + t.push(format!( + "delete_key unknown {:?}", + s.delete_key("nope").map_err(|e| e.0) + )); + t.push(format!( + "scrub_key live {:?}", + s.scrub_key("k1").map_err(|e| e.0) + )); + // The tombstone: written (its instant is the wall clock's, so only its presence is read), never + // cleared by a live-shaped write, and scrubbable once it stands. + t.push(format!( + "delete_key {:?}", + s.delete_key("k1").map_err(|e| e.0) + )); + t.push(format!( + "put_key over tombstone {:?}", + s.put_key(&key).map_err(|e| e.0) + )); + let dead = s + .get_key("k1") + .map(|k| k.map(|k| (k.enabled, k.deleted_at.is_some(), k.revision))); + t.push(format!("get_key tombstoned {:?}", dead.map_err(|e| e.0))); + t.push(format!( + "scrub_key tombstoned {:?}", + s.scrub_key("k1").map_err(|e| e.0) + )); + + let delta = UsageDelta { + requests: 2, + billable_requests: 1, + ..Default::default() + }; + t.push(format!( + "add_usage {:?}", + s.add_usage("b", 60, &delta).map_err(|e| e.0) + )); + t.push(format!( + "add_usage {:?}", + s.add_usage("b", 60, &delta).map_err(|e| e.0) + )); + t.push(format!( + "get_usage {:?}", + s.get_usage("b", 60).map_err(|e| e.0) + )); + + t.push(format!( + "append_audit {:?}", + s.append_audit(&audit(1, "hash-a")).map_err(|e| e.0) + )); + t.push(format!( + "append_audit same {:?}", + s.append_audit(&audit(1, "hash-a")).map_err(|e| e.0) + )); + t.push(format!( + "append_audit fork {:?}", + s.append_audit(&audit(1, "hash-b")).map_err(|e| e.0) + )); + t.push(format!("list_audit {:?}", s.list_audit().map_err(|e| e.0))); + + let head = plane("probe", "t1", None, 0); + t.push(format!( + "upsert {:?}", + s.upsert_plane_record(head.view()).map_err(|e| e.0) + )); + t.push(format!( + "get {:?}", + s.get_plane_record("probe", "t1").map_err(|e| e.0) + )); + for seq in [1, 2] { + let step = plane("probe-step", "s", Some("t1"), seq); + t.push(format!( + "append {:?}", + s.append_plane_record(step.view()).map_err(|e| e.0) + )); + } + let chain = s.list_plane_records("probe-step", &PlaneSelector::Parent("t1".into())); + t.push(format!("list by parent {:?}", chain.map_err(|e| e.0))); + let parents = s.list_plane_record_parents("probe-step"); + t.push(format!("parents {:?}", parents.map_err(|e| e.0))); + + for _ in 0..2 { + let spent = s.redeem_plane_token("probe", "tok", u64::MAX, 5); + t.push(format!("redeem {:?}", spent.map_err(|e| e.0))); + } + let live = s.plane_token_live("probe", "t1", u64::MAX, 5); + t.push(format!("live {:?}", live.map_err(|e| e.0))); + let lapsed = s.plane_token_live("probe", "t1", 4, 5); + t.push(format!("lapsed {:?}", lapsed.map_err(|e| e.0))); + + t.push(format!( + "add_denylist {:?}", + s.add_denylist("sub", "why").map_err(|e| e.0) + )); + t.push(format!( + "list_denylist {:?}", + s.list_denylist().map_err(|e| e.0) + )); + t +} + +/// R-FIX3: THE KERNEL'S STORE DOUBLE STATES WHAT THE LINKED STORE DOOR STATES, so the kernel's tests, +/// which boot on the double and build every `GovState` over it, judge the store the binary ships. +/// Both Statements rendered: the same kind and ABI, `max_inflight`, flag marks, word marks, needs and +/// tail, and the same ephemeral flag on the linked row; the name is the double's own (the kernel names +/// no plugin, C1). Both opened through their doors on the store axis: the same answer to every verb +/// of one script, refusals byte for byte. +/// +/// RED by flipping any copied rule in the double (e.g. `redeem_plane_token` answering `true` twice, +/// or `put_key` clearing a tombstone), or by stating a different tail. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn the_kernel_store_double_states_what_the_linked_store_door_states() { + let row = real_row(); + let (real, double) = (read(row.2), read(store_double::door)); + assert_ne!( + double.name, real.name, + "the double states its own name: the kernel names no plugin (C1)" + ); + assert_eq!(double.name, store_double::NAME, "the Statement name"); + assert_eq!( + store_double::STORE.3, + store_double::NAME, + "the canonical name" + ); + assert_eq!(double.kind, real.kind); + assert_eq!(double.kind_abi, real.kind_abi); + assert_eq!(double.max_inflight, real.max_inflight, "max_inflight"); + assert_eq!(double.marks, real.marks, "the flag marks"); + assert_eq!(double.mark_words, real.mark_words, "every word mark"); + assert_eq!(double.needs, real.needs, "the needs"); + assert_eq!(double.kind_tail_size, real.kind_tail_size, "the tail"); + assert_eq!(store_double::STORE.1, row.1, "the ephemeral flag"); + + let (real_door, double_door) = (row.2, store_double::door); + let (want, got) = tokio::task::spawn_blocking(move || { + let want = transcript(open(real_door, "linked-real").as_ref()); + let got = transcript(open(double_door, "linked-double").as_ref()); + (want, got) + }) + .await + .expect("the blocking scripts ran"); + assert_eq!( + got, want, + "the double answers the script as the linked store does" + ); +} diff --git a/crates/busbar/src/root/tests/serve_money.rs b/crates/busbar/src/root/tests/serve_money.rs index d429107341..40a3edea46 100644 --- a/crates/busbar/src/root/tests/serve_money.rs +++ b/crates/busbar/src/root/tests/serve_money.rs @@ -664,7 +664,7 @@ fn the_nodes_boot_hooks_price_and_book_a_door_unit_in_every_build() { } let run = std::process::Command::new(std::env::current_exe().expect("the test binary")) .args(["--exact", "root::serve::money_tests::node_boot_hooks_arm"]) - .args(["--test-threads", "1", "--nocapture"]) + .args(["--ignored", "--test-threads", "1", "--nocapture"]) .env(NODE_HOOKS_ARM, "1") .output() .expect("the test binary runs"); @@ -682,11 +682,18 @@ fn the_nodes_boot_hooks_price_and_book_a_door_unit_in_every_build() { /// the real boot book; then a keyed door unit of the test plane, dropped in, is driven on the /// process's one node. Its line and its audit record are on that book, and the record names the /// card the unit was pinned to at its door: a second apply's entry, so not the opening's `0`. +/// +/// Ignored so the default run never counts it as a pass: alone it proves nothing, and run without +/// its parent's variable it fails (status row 114). #[tokio::test] +#[ignore = "child process of the_nodes_boot_hooks_price_and_book_a_door_unit_in_every_build, \ + which runs it with --ignored"] async fn node_boot_hooks_arm() { - if std::env::var_os(NODE_HOOKS_ARM).is_none() { - return; - } + assert!( + std::env::var_os(NODE_HOOKS_ARM).is_some(), + "node_boot_hooks_arm is the child half of \ + the_nodes_boot_hooks_price_and_book_a_door_unit_in_every_build: run that, not this" + ); // THE TABLE: the node's root unit is keyed to the node itself, never to a plane feature. assert!( include_str!(concat!(env!("OUT_DIR"), "/linked.rs")) diff --git a/crates/busbar/src/root/tests/shutdown.rs b/crates/busbar/src/root/tests/shutdown.rs new file mode 100644 index 0000000000..09ad73023e --- /dev/null +++ b/crates/busbar/src/root/tests/shutdown.rs @@ -0,0 +1,130 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (C) 2026 Busbar Inc and contributors + +//! THE ONE SHUTDOWN TAIL (audit busbar-src #1; ruling H2 U10; BUSBAR-1.6.0.md THE DESIGN, §7): it +//! returns only once the journal records queued for the configured store have reached it, and both +//! of the process's tails — the listener path's and the line-serve mode's — are this one function. + +use busbar_kernel_ledger::legacy::RecordingRows; +use busbar_kernel_wal::Record; + +use super::*; +use crate::root::durability::{build_on_store, DurabilityConfig, JournalLane}; +use crate::root::kernel::PinnedHistory; +use crate::root::store_double::RecordSlots; + +/// The node the test book is built as. +const NODE: u64 = 31; + +/// How long the store refuses before it recovers: long enough that a tail which does not wait has +/// returned well before, short enough to sit far inside [`RECORD_DRAIN`]. +const OUTAGE: std::time::Duration = std::time::Duration::from_millis(300); + +/// No dated history: the test book prices nothing. +fn no_history() -> Option { + None +} + +/// A book over `slots` with no data directory (the production boot's shape for a child process +/// with no disk), and a handle on the same lane its journal ships through. +fn store_book(slots: &RecordSlots) -> (Arc>, JournalLane) { + let lane = JournalLane::start(slots.calls(), "test-store").expect("the lane starts"); + let durability = build_on_store( + &DurabilityConfig { data_dir: None }, + NODE, + lane.clone(), + Box::new(RecordingRows::new()), + Box::new(no_history), + None, + ) + .expect("the store reads back"); + (Arc::new(Mutex::new(durability)), lane) +} + +/// **THE SHUTDOWN TAIL DRAINS THE JOURNAL'S LANE BEFORE IT RETURNS.** Journal records wait in the +/// lane while the store refuses; the store recovers a moment into the tail. The tail returns only +/// once the store took every one of them, so the process that exits right after it (the line-serve +/// mode's `std::process::exit`) leaves nothing queued. +/// +/// RED before the fix: there was no shared tail (`root::shutdown` did not exist), and the line-serve +/// mode's own tail flushed the budget and metering write-behind and exited, never touching the +/// lane: the records it held at stdin EOF were lost with the process. +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn the_shutdown_tail_drains_the_journal_lane_before_it_returns() { + let slots = RecordSlots::new(); + let (book, lane) = store_book(&slots); + slots.refuse(true); + let before = slots.puts(); + let queued: Vec = (1..=4) + .map(|seq| Record::new(NODE + 1, seq, vec![7u8; 24])) + .collect(); + lane.shipper() + .ship(&queued) + .expect("a refusing store does not refuse the hand-off: the lane has room"); + assert!(lane.pending() > 0, "the records wait for the store"); + + let recovers = { + let slots = slots.clone(); + std::thread::spawn(move || { + std::thread::sleep(OUTAGE); + slots.refuse(false); + }) + }; + let late = LateServices::new(); + tail(None, &late, Some(&book)).await; + + assert_eq!( + lane.pending(), + 0, + "the tail returned with journal records still queued for the store" + ); + assert!( + slots.puts() >= before + queued.len(), + "every queued record reached the store before the tail returned" + ); + recovers.join().expect("the recovery thread"); +} + +/// **BOTH TAILS ARE THE ONE TAIL.** `main.rs` calls [`tail`] from the line-serve branch, before its +/// `std::process::exit`, and from the listener path's end; and it spells none of the tail's steps +/// itself, so neither path can again carry a shorter list than the other. +/// +/// RED before the fix: the line-serve branch flushed the budget and metering write-behind inline +/// and exited without draining the plane records or the journal lane; the listener path spelled +/// its own longer list. +#[test] +fn the_line_serve_tail_and_the_listener_tail_are_the_one_shutdown_tail() { + let main = include_str!("../../main.rs"); + let call = "root::shutdown::tail("; + assert_eq!( + main.matches(call).count(), + 2, + "main.rs runs the shutdown tail once per tail: the line-serve branch and the listener path" + ); + let served = main + .find("serve_lines(") + .expect("main.rs serves the line-serve mode"); + let exit = served + + main[served..] + .find("std::process::exit(code)") + .expect("the line-serve branch exits with the loop's code"); + assert!( + main[served..exit].contains(call), + "the line-serve branch exits without running the shutdown tail" + ); + assert!( + main[exit..].contains(call), + "the listener path ends without running the shutdown tail" + ); + for step in [ + "flush_budgets(", + "flush_metering(", + "drain_records(", + "RECORD_DRAIN", + ] { + assert!( + !main.contains(step), + "main.rs spells the shutdown step `{step}` itself instead of running the one tail" + ); + } +} diff --git a/crates/busbar/tests/cli_validate.rs b/crates/busbar/tests/cli_validate.rs index ca6082bd0e..a3a73d0a20 100644 --- a/crates/busbar/tests/cli_validate.rs +++ b/crates/busbar/tests/cli_validate.rs @@ -1517,10 +1517,10 @@ models: {{}} .unwrap(); } -/// THE DECISIONS PLANE'S DIALECT, discovered black-box rather than spelled: a `decisions:` model whose -/// provider speaks an unknown protocol is refused naming the dialect(s) the plane speaks ("this plane -/// speaks only: ."), so the binary itself says which protocol the linked decisions plane reads. -/// Exactly one is required; a refusal that stops naming it fails here, loudly. +/// THE PLANE'S FIRST DIALECT, discovered black-box rather than spelled: a model whose provider +/// speaks an unknown protocol is refused naming the dialects the plane speaks ("this plane speaks +/// only: , ."), so the binary itself says which protocols the linked plane reads. Exactly +/// two are required; a refusal that stops naming them fails here, loudly. #[cfg(feature = "plane-decisions")] fn decision_dialect() -> String { let dir = fixture_dir("decisions-dialect-probe"); @@ -1541,11 +1541,7 @@ fn decision_dialect() -> String { .and_then(|rest| rest.split('.').next()) .unwrap_or_else(|| panic!("the refusal names the dialects the plane speaks: {stderr}")); let dialects: Vec<&str> = spoken.split(',').map(str::trim).collect(); - assert_eq!( - dialects.len(), - 1, - "the decision plane speaks one dialect: {stderr}" - ); + assert_eq!(dialects.len(), 2, "the plane speaks two dialects: {stderr}"); dialects[0].to_string() } @@ -1570,6 +1566,24 @@ fn validate_ok_on_a_good_decisions_config() { let _ = std::fs::remove_dir_all(&dir); } +/// THE SECOND DIALECT: a model whose provider speaks the plane's second dialect validates clean too. +#[cfg(feature = "plane-decisions")] +#[test] +fn validate_ok_on_a_config_in_the_planes_second_dialect() { + let dir = fixture_dir("plane-ok-second-dialect"); + write_decisions_configs( + &dir, + "nanojev", + "decisions:\n models:\n verdicts:\n provider: mock\n", + ); + let (code, stdout, stderr) = run_busbar(&dir, &["--validate"]); + assert_eq!( + code, 0, + "a config in the second dialect validates: stdout={stdout} stderr={stderr}" + ); + let _ = std::fs::remove_dir_all(&dir); +} + /// DEFECT 1: `decisions.models..provider` names a provider absent from `providers:`. #[cfg(feature = "plane-decisions")] #[test] diff --git a/crates/busbar/tests/common/boot.rs b/crates/busbar/tests/common/boot.rs index 5d7c6320d3..159657d371 100644 --- a/crates/busbar/tests/common/boot.rs +++ b/crates/busbar/tests/common/boot.rs @@ -12,6 +12,7 @@ //! `busbar --version` to completion once per test process before handing the path out: the //! first-exec cost is paid before any clock starts, and the deadline keeps its value. +use std::path::Path; use std::process::Command; use std::sync::OnceLock; @@ -107,3 +108,27 @@ pub fn try_reserve(port: u16) -> Option { .expect("the port-claim file"); file.try_lock().ok().map(|()| file) } + +/// Whether the node's log shows BOTH listeners up: one "busbar listening" line from the data side +/// (written by data worker 0) and one from the admin side (written once the control thread binds +/// admin). The FIRST line alone is not enough: it lands before admin is bound, so an admin call +/// fired on it is refused (ConnectionRefused). +pub fn log_shows_data_and_admin_listening(log: &str) -> bool { + log.matches("busbar listening").count() >= 2 +} + +/// The node's whole log, printed to the test's stderr when the test panics while this guard is +/// held, so a red shows what the node said (a refused connection names no cause on its own). +pub struct DumpOnPanic<'a>(pub &'a Path); + +impl Drop for DumpOnPanic<'_> { + fn drop(&mut self) { + if std::thread::panicking() { + eprintln!( + "--- the node's log ({}):\n{}", + self.0.display(), + std::fs::read_to_string(self.0).unwrap_or_default() + ); + } + } +} diff --git a/crates/busbar/tests/fixtures/plane_driver_test_plane.rs b/crates/busbar/tests/fixtures/plane_driver_test_plane.rs index 027df976e0..794a82cb1a 100644 --- a/crates/busbar/tests/fixtures/plane_driver_test_plane.rs +++ b/crates/busbar/tests/fixtures/plane_driver_test_plane.rs @@ -1165,6 +1165,14 @@ extern "C" fn on_piece( *i.fields_buf = OutField { name, value }; o.fields_written = 1; o.arena_written = at as u64; + // A plane whose usage tap could not read the answer (modes `tap-bad-json`, + // `tap-unknown`, the latter a byte outside the vocabulary): the fault rides the answer + // that opens the reply, once. + o.tap_fault = match u.mode.as_slice() { + b"tap-bad-json" => busbar_contract::abi::plane::TAP_FAULT_BAD_JSON, + b"tap-unknown" => 0xC8, + _ => busbar_contract::abi::plane::TAP_FAULT_NONE, + }; } let n = u.pending.len().min(i.reply_cap); std::ptr::copy_nonoverlapping(u.pending.as_ptr(), i.reply_buf, n); diff --git a/crates/busbar/tests/ledger_identity.rs b/crates/busbar/tests/ledger_identity.rs index aac2eb4a02..ed4704a99e 100644 --- a/crates/busbar/tests/ledger_identity.rs +++ b/crates/busbar/tests/ledger_identity.rs @@ -687,6 +687,25 @@ impl Rig { PORTS.data, read_to_string(&rig.log_path) ); + // The data door answering says nothing about the admin door: the control thread binds it + // after the data workers. Minting before it answers fails as status 0 (connection refused) + // under load. Ready means the admin listener ANSWERS (any HTTP status), so wait for that + // rather than for a delay. + let admin_up = wait_until(Duration::from_secs(60), || { + if let Some(status) = rig.child.try_wait().expect("try_wait") { + panic!( + "busbar exited before its admin door answered (status {status:?}); log:\n{}", + read_to_string(&rig.log_path) + ); + } + get(PORTS.admin, "/api/v1/admin/keys", Some(ADMIN_TOKEN)).status != 0 + }); + assert!( + admin_up.is_some(), + "busbar's admin door did not answer on {}; log:\n{}", + PORTS.admin, + read_to_string(&rig.log_path) + ); rig.key_ok = rig.mint(r#"{"name":"identity-ok","group":"oracle"}"#); rig.key_broke = rig.mint(r#"{"name":"identity-broke","group":"broke"}"#); diff --git a/crates/busbar/tests/net_guard_one_judge.rs b/crates/busbar/tests/net_guard_one_judge.rs index 4a8ab47eab..2a418e9255 100644 --- a/crates/busbar/tests/net_guard_one_judge.rs +++ b/crates/busbar/tests/net_guard_one_judge.rs @@ -17,13 +17,15 @@ //! //! ## What it is now //! -//! There is one definition. It lives in [`busbar_kernel_egress::trust::net`], and -//! `busbar_kernel::net_guard` is a re-export shim over it. So the two names below are two paths to -//! ONE function, and every equality in this file is trivially true — *while that stays true*. +//! There is one definition. It lives in [`busbar_kernel_egress::trust::net`]. The kernel's +//! `net_guard` re-export shim is gone (row 130 E2); the kernel reaches the judge's config arm as +//! `busbar_kernel::config_validate::{extract_normalized_host, ssrf_blocked_host}`, re-exports of +//! the unit's. So the two names below are two paths to ONE function, and every equality in this +//! file is trivially true — *while that stays true*. //! //! That is precisely what this file now gates. The failure mode it watches for is no longer "the //! two copies disagree" but "somebody re-grew a second copy behind one of these names", and the -//! shape of that regression is identical: a local definition under `busbar_kernel::net_guard` that +//! shape of that regression is identical: a local definition under `busbar_kernel::config_validate` that //! answers some hostile spelling differently from the unit. The rows below are chosen to be the //! spellings a re-fork would get wrong — in particular [`BARE_AUTHORITIES`], which the shim's //! predecessor answered `None` for while the unit judged them, and the metadata NAMES, which is @@ -97,7 +99,7 @@ const HOSTILE: &[&str] = &[ /// spell its destination either way and the operator's denylist fires for both. /// /// They stay in this file as the sharpest re-fork detector it has. A second copy grown behind -/// `busbar_kernel::net_guard` would almost certainly be the OLD shape — scheme-only extraction, +/// `busbar_kernel::config_validate` would almost certainly be the OLD shape — scheme-only extraction, /// `None` for every row here — so this is the table where "somebody re-forked the guard" shows up /// as a refusal that silently stopped happening. const BARE_AUTHORITIES: &[&str] = &[ @@ -117,11 +119,11 @@ const BARE_AUTHORITIES: &[&str] = &[ fn both_names_reach_one_extraction() { let mut compared = 0usize; for spelling in HOSTILE.iter().chain(BARE_AUTHORITIES) { - let through_kernel = busbar_kernel::net_guard::extract_normalized_host(spelling); + let through_kernel = busbar_kernel::config_validate::extract_normalized_host(spelling); let through_unit = busbar_kernel_egress::trust::net::extract_normalized_host(spelling); assert_eq!( through_kernel, through_unit, - "`busbar_kernel::net_guard` and the egress unit read {spelling:?} as different hosts — \ + "`busbar_kernel::config_validate` and the egress unit read {spelling:?} as different hosts — \ the shim has grown a second definition, and whichever one a call site routes through \ now decides whether this destination is guarded" ); @@ -149,13 +151,18 @@ fn both_names_reach_one_denylist_judgement() { for spelling in HOSTILE.iter().chain(BARE_AUTHORITIES) { // The bare guard, no operator lists at all. assert_eq!( - busbar_kernel::net_guard::ssrf_blocked_host(spelling, &[], false, &[]), + busbar_kernel::config_validate::ssrf_blocked_host(spelling, &[], false, &[]), busbar_kernel_egress::trust::net::ssrf_blocked_host(spelling, &[], false, &[]), "bare guard disagrees on {spelling:?}" ); // With the operator's denylist — the arm the trailing-space bypass defeated. assert_eq!( - busbar_kernel::net_guard::ssrf_blocked_host(spelling, &[], false, &operator_blocked), + busbar_kernel::config_validate::ssrf_blocked_host( + spelling, + &[], + false, + &operator_blocked + ), busbar_kernel_egress::trust::net::ssrf_blocked_host( spelling, &[], @@ -166,7 +173,7 @@ fn both_names_reach_one_denylist_judgement() { ); // With a surgical allow-override, which must win through both names or through neither. assert_eq!( - busbar_kernel::net_guard::ssrf_blocked_host( + busbar_kernel::config_validate::ssrf_blocked_host( spelling, &operator_allowed, false, @@ -197,7 +204,7 @@ fn the_bare_authority_arm_is_reached_through_the_kernel_name() { for spelling in BARE_AUTHORITIES { for blocked in [&[][..], &operator_blocked[..]] { let through_kernel = - busbar_kernel::net_guard::ssrf_blocked_host(spelling, &[], false, blocked); + busbar_kernel::config_validate::ssrf_blocked_host(spelling, &[], false, blocked); let through_unit = busbar_kernel_egress::trust::net::ssrf_blocked_host(spelling, &[], false, blocked); assert_eq!( @@ -229,30 +236,30 @@ fn the_bare_authority_arm_is_reached_through_the_kernel_name() { /// [`judge_host_name`]: busbar_kernel_egress::trust::net::judge_host_name #[test] fn the_dialing_arm_and_the_config_arm_read_the_same_metadata_list() { - let names = busbar_kernel::net_guard::METADATA_HOSTS; + let names = busbar_contract::net::METADATA_HOSTS; assert_eq!( names, busbar_kernel_egress::trust::net::METADATA_HOSTS, - "the shim and the unit publish different metadata lists" + "the contract and the unit publish different metadata lists" ); assert!( names.len() >= 6 && names.contains(&"metadata.platformequinix.com"), "the metadata list shrank back toward the two-entry copy: {names:?}" ); - let permissive = busbar_kernel::net_guard::GuardPolicy { + let permissive = busbar_kernel_egress::trust::net::GuardPolicy { allow_private: true, - ..busbar_kernel::net_guard::GuardPolicy::default() + ..busbar_kernel_egress::trust::net::GuardPolicy::default() }; for name in names { // The DIALING arm, under the permissive stance: the metadata arm is unconditional, so // `allow_private` must not reach it. assert!( - busbar_kernel::net_guard::judge_host_name(name, permissive).is_err(), + busbar_kernel_egress::trust::net::judge_host_name(name, permissive).is_err(), "`{name}` is on the metadata list and the dialing arm let it through" ); // And the CONFIG arm, over the same name spelled as a URL. assert!( - busbar_kernel::net_guard::ssrf_blocked_host( + busbar_kernel::config_validate::ssrf_blocked_host( &format!("https://{name}/latest/meta-data/"), &[], false, @@ -271,7 +278,7 @@ fn the_hostile_table_actually_reaches_the_guard_through_both_names() { let mut blocked_through_kernel = 0usize; let mut blocked_through_unit = 0usize; for spelling in HOSTILE { - if busbar_kernel::net_guard::ssrf_blocked_host(spelling, &[], false, &[]).is_some() { + if busbar_kernel::config_validate::ssrf_blocked_host(spelling, &[], false, &[]).is_some() { blocked_through_kernel += 1; } if busbar_kernel_egress::trust::net::ssrf_blocked_host(spelling, &[], false, &[]).is_some() diff --git a/crates/busbar/tests/no_data_dir_neutrality.rs b/crates/busbar/tests/no_data_dir_neutrality.rs index 452ebd5475..74a31c6d6e 100644 --- a/crates/busbar/tests/no_data_dir_neutrality.rs +++ b/crates/busbar/tests/no_data_dir_neutrality.rs @@ -162,6 +162,8 @@ fn no_ledger_series_and_no_keyset_lines_without_data_dir() { .stderr(log_err) .spawn() .expect("spawn busbar"); + // Any panic from here on prints the node's whole log (its stdout and stderr) with it. + let _dump = common::boot::DumpOnPanic(&log_path); let booted = wait_for(Duration::from_secs(30), || { if let Some(status) = child.try_wait().expect("try_wait") { @@ -170,11 +172,11 @@ fn no_ledger_series_and_no_keyset_lines_without_data_dir() { read_to_string(&log_path) ); } - read_to_string(&log_path).contains("busbar listening") + common::boot::log_shows_data_and_admin_listening(&read_to_string(&log_path)) }); assert!( booted, - "busbar did not reach 'listening' within 30s; log:\n{}", + "busbar did not reach 'listening' (data and admin) within 30s; log:\n{}", read_to_string(&log_path) ); // The boot log line lands the instant ANY of the per-core SO_REUSEPORT workers starts listening, diff --git a/crates/busbar/tests/oracle_one_pin.rs b/crates/busbar/tests/oracle_one_pin.rs index f4fb30bbae..65f4db1f14 100644 --- a/crates/busbar/tests/oracle_one_pin.rs +++ b/crates/busbar/tests/oracle_one_pin.rs @@ -108,6 +108,29 @@ fn the_engine_clone_stores_no_token_and_builds_locked() { ); } +/// THE JUDGE IS THE PINNED DIGEST'S OR THERE IS NONE (OWNER 2026-10-13: no hand-installed tools). +/// On Linux `bin/oracle` fetches busbar-release's published `busbar-oracle-linux--` and +/// runs it only when its sha256 is the BASE pin's line for this architecture; a `.sig` beside it must +/// be the release key's. `bin/oracle fetch-selftest` proves, offline: the pinned judge is fetched and +/// cached, a wrong sha256 refuses, a pin with no line for this architecture has "no judge", a +/// disagreeing `.sha256` and another key's `.sig` refuse, and the base's pin wins over the tree's. +#[test] +fn the_fetched_judge_is_the_pinned_digests_or_none() { + let shim = repo_root().join("bin/oracle"); + let out = std::process::Command::new("bash") + .arg(&shim) + .arg("fetch-selftest") + .current_dir(repo_root()) + .output() + .expect("run bin/oracle fetch-selftest"); + assert!( + out.status.success(), + "bin/oracle fetch-selftest failed:\n{}{}", + String::from_utf8_lossy(&out.stdout), + String::from_utf8_lossy(&out.stderr) + ); +} + /// NO ARM EXECS AN ENGINE SUBCOMMAND THE PIN NO LONGER HAS: busbar-release #272 deleted the /// engine's `owed-baseline` and `replay` subcommands, so `bin/oracle owed-baseline` and the generated /// `$BUSBAR_ORACLE_TOOL_DIR/replay.sh` (`exec busbar-oracle replay`) could only end in clap's diff --git a/crates/busbar/tests/plane_driver.rs b/crates/busbar/tests/plane_driver.rs index bcd2f053d3..bc1a92d0af 100644 --- a/crates/busbar/tests/plane_driver.rs +++ b/crates/busbar/tests/plane_driver.rs @@ -788,7 +788,8 @@ fn zero_piece_out() -> OnPieceOut { target: span, need: 0, fault: 0, - _fault_reserved: [0; 3], + tap_fault: 0, + _fault_reserved: [0; 2], lane: span, final_status: 0, _final_reserved: 0, diff --git a/crates/busbar/tests/scrape_shape_1_5_5.rs b/crates/busbar/tests/scrape_shape_1_5_5.rs index fb3e411080..04d2580631 100644 --- a/crates/busbar/tests/scrape_shape_1_5_5.rs +++ b/crates/busbar/tests/scrape_shape_1_5_5.rs @@ -212,6 +212,8 @@ fn a_1_5_5_shaped_config_exposes_no_plane_series_with_every_plane_compiled_in() .stderr(log_err) .spawn() .expect("spawn busbar"); + // Any panic from here on prints the node's whole log (its stdout and stderr) with it. + let _dump = common::boot::DumpOnPanic(&log_path); let booted = wait_for(Duration::from_secs(30), || { if let Some(status) = child.try_wait().expect("try_wait") { @@ -220,11 +222,11 @@ fn a_1_5_5_shaped_config_exposes_no_plane_series_with_every_plane_compiled_in() read_to_string(&log_path) ); } - read_to_string(&log_path).contains("busbar listening") + common::boot::log_shows_data_and_admin_listening(&read_to_string(&log_path)) }); assert!( booted, - "busbar did not reach 'listening' within 30s; log:\n{}", + "busbar did not reach 'listening' (data and admin) within 30s; log:\n{}", read_to_string(&log_path) ); diff --git a/crates/plugin-loader/src/tests/dispatch_tests.rs b/crates/plugin-loader/src/tests/dispatch_tests.rs index 4030af015d..52626290dc 100644 --- a/crates/plugin-loader/src/tests/dispatch_tests.rs +++ b/crates/plugin-loader/src/tests/dispatch_tests.rs @@ -1553,11 +1553,14 @@ fn the_pack_time_rendering_of_a_library_is_its_door_s() { ); } -/// The child half of the abort test: runs only when its parent sets the variable. +/// The child half of the abort test: runs only when its parent sets the variable. Ignored so the +/// default run never counts it as a pass: alone it proves nothing, and run without the variable it +/// fails (status row 114). #[test] +#[ignore = "child process of red_a_panic_in_a_hand_written_slot_aborts, which runs it with --ignored"] fn panic_child() { let Ok(origin) = std::env::var("BUSBAR_M1_PANIC_CHILD") else { - return; + panic!("panic_child is the child half of red_a_panic_in_a_hand_written_slot_aborts"); }; let p = if origin == "dropped" { dropped(quiet()) @@ -1581,6 +1584,7 @@ fn red_a_panic_in_a_hand_written_slot_aborts() { .args([ "--exact", "dispatch_tests::panic_child", + "--ignored", "--test-threads=1", "--nocapture", ]) diff --git a/docs/ci/actions-pins.md b/docs/ci/actions-pins.md index 93e848d582..c3f9f461dc 100644 --- a/docs/ci/actions-pins.md +++ b/docs/ci/actions-pins.md @@ -63,8 +63,8 @@ PR that preserves the trailing tag comment. ## Every action currently in the tree -Every `uses:` in `.github/workflows/` (`promote.yml` and the reusable `plugin-*.yml` workflows) is one -of the nine distinct `(action, ref)` pairs above, pinned to a commit SHA with its tag kept as a +Every `uses:` in `.github/workflows/` (the reusable `plugin-*.yml` workflows; `promote.yml` is deleted) +is one of the distinct `(action, ref)` pairs above, pinned to a commit SHA with its tag kept as a trailing comment. This table is the independent re-verification of that state. There is no `.github/actions` composite action beyond `cargo-home`, and `R14` is proven able to catch a floating tag inside one by `r14_reads_uses_out_of_a_composite_action_too_not_only_workflows` in diff --git a/docs/config-at-a-glance.md b/docs/config-at-a-glance.md index 7faf3fbff1..7abc5684ee 100644 --- a/docs/config-at-a-glance.md +++ b/docs/config-at-a-glance.md @@ -235,7 +235,7 @@ providers_file: providers.yaml # provider catalog pointer (overridden by the -- plane declares. The fix is additive: add the `units:` map above (every LLM open class at `0`) to any one entry of the flat/`pools` card (declared classes per plane: `pools` = `input`, `output`, `cache_read`, `cache_write`, `search_units`, `classifications`, `web_fetch_requests`, `unitemized_tokens`, `images`, `audio_ms`, `guardrail_automated_reasoning_policies`, `guardrail_automated_reasoning_policy_units`, `guardrail_content_policy_image_units`, `guardrail_content_policy_units`, `guardrail_contextual_grounding_policy_units`, `guardrail_sensitive_information_policy_free_units`, `guardrail_sensitive_information_policy_units`, `guardrail_topic_policy_units`, `guardrail_word_policy_units` · `tools` = `tool_calls`, `bytes` · `agents` = `bytes` · - `decisions` = `decision` · `streams` = `audio_tokens_in`, `audio_tokens_out`, `text_tokens_in`, + `decisions` = `decision`, `input_tokens`, `output_tokens` · `streams` = `audio_tokens_in`, `audio_tokens_out`, `text_tokens_in`, `text_tokens_out`, `cached_tokens`, `audio_seconds_in`, `tool_calls`). A plane section with no card of its own is unaffected. - **[Validation](configuration.md#startup-validation-summary)**: `busbar --validate` runs the exact diff --git a/docs/design/1.6.0-QUESTIONS.md b/docs/design/1.6.0-QUESTIONS.md index bb3c2fddd9..8bacb27058 100644 --- a/docs/design/1.6.0-QUESTIONS.md +++ b/docs/design/1.6.0-QUESTIONS.md @@ -70,7 +70,7 @@ reading this cold, after the fact, with no memory of your slot. | Q39 | P2-verifyrunner | Q5 follow-up — OWNER ACTION (infra) | The getbusbar.com checks now run in their own `verify-site` job on the self-hosted `latchkey-small` runners (d33648d43). But the old combined job was ALREADY on `latchkey-small` when Cloudflare answered 403/429 — so Cloudflare is blocking those runners' egress IPs too. ACTION for you in Cloudflare (getbusbar.com zone): add a WAF custom rule / IP Access rule that SKIPS bot fight mode + rate limiting for the latchkey runners' egress IP(s) (or for a secret header — tell me the header name and I wire it from a repo secret). Nothing in this repo can clear it | workflow side done | **ANSWERED 2026-10-01 (owner): the ARCHITECT adds the Cloudflare WAF skip rule (secret-header bypass) itself.** DONE 2026-10-02: the getbusbar.com custom rule 'busbar release-gate site checks' skips WAF, managed rules, rate limiting and security level for header `X-Busbar-Verify` equal to the repo secret `SITE_VERIFY_TOKEN` (the existing site-curl.sh wiring). Free plan: Bot Fight Mode cannot be skipped by rules and stays on. | | Q40 | W2.2 | 281 (operator /metrics — DECIDE) + 283/556/570 (told) | (a) DECIDE — `busbar_lane_state{pool}` today reads a lane's state across ALL pools: a pool whose own breaker cell is Open while a sibling pool's cell for the same lane is healthy reads 1 (half-open). The fix makes the gauge read that pool's OWN cell: it then reads 2 (tripped) — correct per pool — but state 1 (half-open) becomes unreachable for pool lanes unless the per-pool half-open probe state is exported too. Options: (1) per-pool own cell AND export the per-pool half-open state so 0/1/2 all stay meaningful — ARCHITECT RECOMMENDATION; (2) per-pool own cell, 1 unreachable, document it; (3) keep the cross-pool reading. (b) told: two plane claims differing only in ALPN case no longer refuse boot (contract byte-exact rule; no shipped plane declares such a pair); a hook plugin's diagnostics now reach the log; "OTLP tracing enabled" is no longer printed when the exporter failed | (a) patch ready, not landed; (b) FIXED | (a) the gauge keeps the cross-pool reading | | Q41 | P2-coreadmin | openapi.json drift (DECIDE — §9.6 artifact) | Your Q18/Q22 ruling reverted the pre-session openapi edit, but the openapi drift test is still RED at HEAD: COMMITTED code already generates exactly those hunks — (1) `scheme` is required on AuditEntry (the audit record carries its scheme byte, pinned by W2.9/7c3306990), (2) POST /keys/{id}/rotate documents the 400 invalid_request Overlong response the handler really emits, (3) a restart_required_default doc-path rename. Choices: (a) REGENERATE openapi.json(.gz) so the published spec describes what the code does — ARCHITECT RECOMMENDATION (the spec must not lie; each behaviour is already in the tree); (b) revert the code producing those three hunks so the document stays byte-identical to the pre-session HEAD | openapi.json = HEAD + the Q25b 409 only | drift test stays red | -| Q42 | P2-billunits | generic billable-class boot rule (Q29/Q35 — FIXED, told) | Landed exactly as ruled. Upgrade impact to publish: EVERY 1.5.5 config with a `rate_card` now FAILS BOOT with `pools.rate_card does not configure billable unit(s) search_units declared by this plane; add them (0 to make them free)` — fix: add `units: { search_units: 0 }` to any card entry. Declared classes per plane: pools = input, output, cache_read, cache_write, search_units · tools = tool_calls, bytes · agents = bytes · decisions = decision · streams = audio_tokens_in, audio_tokens_out, text_tokens_in, text_tokens_out, cached_tokens, audio_seconds_in, tool_calls. For the 1.5.5 oracle (1.5.5 rejects `units:`), the Phase 3 harness adds these classes at 0 to the 1.6.0 run's config only — the operator upgrade step — golden untouched | FIXED (dbc02c8b9) | — | +| Q42 | P2-billunits | generic billable-class boot rule (Q29/Q35 — FIXED, told) | Landed exactly as ruled. Upgrade impact to publish: EVERY 1.5.5 config with a `rate_card` now FAILS BOOT with `pools.rate_card does not configure billable unit(s) search_units declared by this plane; add them (0 to make them free)` — fix: add `units: { search_units: 0 }` to any card entry. Declared classes per plane: pools = input, output, cache_read, cache_write, search_units · tools = tool_calls, bytes · agents = bytes · decisions = decision, input_tokens, output_tokens · streams = audio_tokens_in, audio_tokens_out, text_tokens_in, text_tokens_out, cached_tokens, audio_seconds_in, tool_calls. For the 1.5.5 oracle (1.5.5 rejects `units:`), the Phase 3 harness adds these classes at 0 to the 1.6.0 run's config only — the operator upgrade step — golden untouched | FIXED (dbc02c8b9) | — | | Q43 | P2-voice | Q21b voice invoices (FIXED as ruled — told) + Q21a | Voice now bills on the streaming plane's units, priced by `streams.rate_card` at read: audio seconds and tool calls start billing; with NO streams card voice reads 0 even if an llm card exists (it used to be priced by the llm card, and a present llm card missing the model closed the session on its first turn). Fixed script (card 1/token, streams card 1/count): before 18 (lease stored 180,000,000 nano), after 20 (audio_tokens_out 11, text_tokens_out 4, text_tokens_in 3, audio_seconds_in 1, tool_calls 1), nothing stored. A key whose requests/tokens cap is exhausted is refused a voice session (402); a live session closes when its budget runs dry from any traffic. Q21a: no session is cut unless `streams.session_max_secs` is set (then a `session_expired` error frame + settle). Being fixed: `cached_tokens` is a subset of input tokens so pricing it would double-bill — dropped from the billable classes (disjoint-classes rule) | FIXED (f87fd3800, 2beb4fb96) | — | | Q44 | P2-fees | Q32 per-plane fees (FIXED as ruled — told) | Customer-visible money moves (24bb507d0): (1) MCP and A2A calls no longer carry the pools `per_request_fee` — budget gate, /keys and /groups usage, /metrics spend gauges all move: flat fee 5, 4 MCP calls on a plane with no `fees:` 20 → 0; a group at 3/day that used to 429 on MCP call 1 is now served and reads 0; with tools.fees.per_request 3, 2 LLM + 3 MCP calls 25 → 19. (2) New optional config `
.fees: {per_request, per_session}` (additive): 2 voice sessions at per_session 40 → 80. (3) Durable budget rows gain a `␟` model entry for fee counts; durable billable_requests counts pools-plane requests only. (4) /metrics gains a zero-valued `␟` model series. (5) A session fee is counted after the open-time dry check, so one session can pass a cap by at most one session fee. Being fixed by the architect: GET /admin/usage still prices MCP metering rows at the flat fee — made to agree with the budget book. | | Q45 | P2-landpatches | rerank search units (FIXED — told) | Customer-visible money move (d7bcbaf2b): a same-protocol Cohere→Cohere or Bedrock→Bedrock rerank now reports its search units to both books. With card `rerank-v3.5: { units: { search_units: 2000 } }`, a rerank billing 50 search units goes from 0 to 10 cents on the invoice and /usage. A present card that does not price search_units now refuses it (#42); no card still reads 0. A cross-protocol rerank with a Bedrock egress now bills its search units, where before it billed flat. A Bedrock rerank body whose search_units is present but unreadable is now refused (item 133 rule). | diff --git a/docs/design/BUSBAR-1.6.0.md b/docs/design/BUSBAR-1.6.0.md index 7587e98f37..f75e056d15 100644 --- a/docs/design/BUSBAR-1.6.0.md +++ b/docs/design/BUSBAR-1.6.0.md @@ -1462,9 +1462,11 @@ completes) — no verified-credential cache of its own. This supersedes the "sta unresolved until Q-INCACHE was answered" line above and closes Q-INCACHE. **R4 — Q-DISK.** A host-owned bounded disk lane is the ONE exception to "no blocking" (§11.2's rule -otherwise holds absolutely): it exists solely for the SQLite store and the file export sink, both of -which are inherently local-disk-bound and cannot be made sans-IO without becoming a different product. -No other kind may claim this exception. +otherwise holds absolutely): it exists solely for the SQLite store, the file export sink, and the +secret kind's file source (config-time only; OWNER 2026-10-13), each inherently local-disk-bound and +not sans-IO without becoming a different product. The secret kind's file source reads on the lane only +at config time (boot, config apply, refresh) and is never reachable from a request path; a gate holds +that. No other kind may claim this exception. **R5 — Q-MYSQL.** A sans-IO mysql store and a sans-IO ldap auth plugin are built (the `mysql-ldap-stream` ruling, Q82, patches both drivers to take a host-supplied stream); both ship in the default distribution. @@ -2893,7 +2895,7 @@ shape guarantee each is a clean add, never a break. That is "fits-or-cleanly-ext > definition and the owner rulings that shape execution. ## DONE = DEV-GREEN (the definition every wave drives to) -DEV-GREEN is reached when ALL of the following hold and turnstile boards ADMIT: +DEV-GREEN is reached when ALL of the following hold ~~and turnstile boards ADMIT~~ and ModernCI's `predev` → `dev` move (`.ci/predev.yaml`) is green and lands (Part 6, "BUSBAR-RELEASE RETIRED FROM BUSBAR", OWNER 2026-10-12/13): 1. **Oracle byte-identical vs the 1.5.5 golden on every family** (money sacred, #9/#10). The ONLY accepted diffs are the entries of `testing/shadow-oracle/accepted-differences.json`, all 45 re-signed by the owner 2026-09-30. Everything else byte-green. @@ -2911,7 +2913,7 @@ DEV-GREEN is reached when ALL of the following hold and turnstile boards ADMIT: independent architecture audit on the DEV-GREEN candidate sha, graded against the Laws and THE DESIGN; it passes with no open BLOCK findings. The "two audits ≥9/10" requirement is struck (#60; the §11a baseline that score depended on never existed). -6. **turnstile BOARD = ADMIT → train ff `predev`→`dev` = DEV-GREEN** (#32/#67). +6. ~~**turnstile BOARD = ADMIT → train ff `predev`→`dev` = DEV-GREEN** (#32/#67).~~ SUPERSEDED 2026-10-13 by Part 6, "BUSBAR-RELEASE RETIRED FROM BUSBAR": **ModernCI's `predev` → `dev` move green on `.ci/predev.yaml` and landed = DEV-GREEN.** ## money-touching step shadow-oracle-gated; each wave loops `/codeaudit` to 2 clean passes #60) @@ -2984,8 +2986,8 @@ Agents: (a) land `integration/conformance-b`; conformance MUST-set green for all arm + green every gate in the DONE battery above (each RED-provable, red-before-green). ### W8 — BOARD → DEV-GREEN [#32/#67] -turnstile runs gates + oracle + conformance → BOARD verdict. On ADMIT, the train fast-forwards -`predev`→`dev`. **DEV-GREEN.** +~~turnstile runs gates + oracle + conformance → BOARD verdict. On ADMIT, the train fast-forwards +`predev`→`dev`.~~ ModernCI runs `.ci/predev.yaml` (gates, oracle, conformance, the done-oracle, the Q128 audit) on the `predev` → `dev` move; green, it lands. **DEV-GREEN.** --- @@ -3110,14 +3112,60 @@ Everything in Parts 1–5 describes the product. This Part describes the machine lives in **two repos**, and that split is the single most common source of "I can't find the autoscaler" confusion. +## BUSBAR-RELEASE RETIRED FROM BUSBAR; MODERNCI IS BUSBAR'S CI (OWNER 2026-10-12, 2026-10-13) + +> **OWNER 2026-10-12:** *"ModernCI only."* **OWNER 2026-10-13:** *"delete anything you don't need +> anymore, yes"* and *"Yes, we keep the busbar-release repo, slimmed down. ModernCI replaces its CI +> half; the busbar-specific tools stay."* This section governs Part 6. Every passage below that +> names the hop, the turnstile, the train, the conductor, the runner gateway, `promote.yml` or the +> engine pin is SUPERSEDED by it and is struck where it stands. + +**ModernCI judges and moves every rung, from busbar's own branch files** (ModernCI's answer, +2026-10-13: a branch file per moving branch, `promote: auto` opens the promotion, `on.landed` steps +run on each new target tip): + +| Move | Branch file | What it runs | +|---|---|---| +| `lane-*` → `predev` | `.ci/default.yaml` | preflight, gates, clippy, compile, test, doctest, deletion-matrix, deny; no worse than predev; ModernCI's merge train lands it (required check `ci`) | +| `predev` → `dev` | `.ci/predev.yaml` | gates, the oracle against the 1.5.5 golden, conformance, `verify-1.6.0-done.sh`, the Q128 audit; the owner's approval. **Its green landing is DEV-GREEN** (Part 5) | +| `dev` → `qa` | `.ci/dev.yaml` | the `dev` rung plus build and test, the fuzz smoke and the signing gate (today `qa/pipeline-checks.json`), the soak, the release binaries and the container; the owner's approval | +| `qa` → `main` | `.ci/qa.yaml` | the same battery; on landing, the `release` step signs and publishes the build it judged (no rebuild) and a `run` step does cosign and the container push, with secrets scoped to `on_landed` | + +**busbar-release stays, as the tools repo** (judged by ModernCI, not by its own Actions, once +ModernCI registers it). KEEP: `busbar-release-oracle` (the money judge `busbar-oracle`, pinned by +`testing/shadow-oracle/oracle-rust.pin`: one commit, one sha256 per architecture), with +`busbar-release-fetch` and `busbar-release-golden-scope`; the published `busbar-oracle-linux--` +assets every pin names, kept for as long as a busbar branch pins them; `oracle-rerecord` (the +add-only golden recorder); `busbar-release-soak`; `busbar-release-plugin` (the fleet generator: +`plugin new|sync|check`, `template/`) and its daily `plugin-drift` check, which stays on Actions +until ModernCI's scheduled checks are live (ModernCI PR #91); `busbar-release-github` (the plugin +generator and the soak call GitHub through it); and the `busbar-release` command line, cut to those +verbs. DELETE: the conductor and its state repo, the turnstile, the hop, the train and the ladder +(`ci` verbs, `ladder.json`, `preflight.json`), the runner gateway (`busbar-release-runners`, +`vars.RUNNERS` once the last workflow that reads it is gone), the EC2 backend and autoscaler, the +runner canary and probe, the `dev` dispatch workflow, the engine build on `dev-bin`, and the engine +pin (the org variables `ENGINE_REF`, `ENGINE_SHA256`, `ENGINE_JUDGE`, and the R2 cache keys the +hop alone read). In busbar: `.github/workflows/promote.yml` and the `workflow-rules` rows that +held it (R16 the engine pin, R18 its release-artifact wiring, R19 its R2 keys). + +**The oracle and the golden do not move.** The 1.5.5 golden stays in +`testing/shadow-oracle/golden/1.5.5`, read-only and add-only (above, "The 1.5.5 golden is +read-only"). The judge is built from busbar-release at the pinned commit; ModernCI runs only that +pinned install, checked against the pin's sha256 for its host, and a candidate never picks its own +judge. + +**The plugin fleet.** Each plugin repo is judged by ModernCI once it is registered there with its +own branch files; until then the reusable `plugin-ci.yml`, `plugin-consumer-verify.yml`, +`plugin-release.yml` and `plugin-repin.yml` stay, because the fleet's rendered callers name them. + ## The branch roster (#67) — four branches, and no others | Branch | Rule | CI | |---|---|---| -| `predev` | **permanent WIP.** Every in-flight session lands here and forks from here. | Pull requests only, landed through GitHub's merge queue; `promote.yml` `preflight` + `hop` are the required checks on both the pull request (the fast pre-check) and the merge group (the full plan, the proof of record), and the verdict is "no worse than base" (see "How work lands" below). | -| `dev` | **release-train-write-only.** "Next version's WIP, nowhere near done." | full `ci.yml` | -| `qa` | promotion target. **The one release build happens here** — the PGO build that ships, tested for real. | full CI + the real-media matrix | -| `main` | **a push here cuts a release** — tag, GitHub Release, container promotion, `latest` moved. Irreversible. **`main` never compiles:** it tags and publishes the artifacts, by digest, that `qa` built and verified. | release orchestration | +| `predev` | **permanent WIP.** Every in-flight session lands here and forks from here. | ~~Pull requests only, landed through GitHub's merge queue; `promote.yml` `preflight` + `hop` are the required checks on both the pull request (the fast pre-check) and the merge group (the full plan, the proof of record), and the verdict is "no worse than base" (see "How work lands" below).~~ SUPERSEDED 2026-10-13 by "BUSBAR-RELEASE RETIRED FROM BUSBAR" above: pull requests only, judged by ModernCI on `.ci/default.yaml` (required check `ci`), no worse than predev, landed by ModernCI's merge train. | +| `dev` | **release-train-write-only.** "Next version's WIP, nowhere near done." | ~~full `ci.yml`~~ SUPERSEDED 2026-10-13 by "BUSBAR-RELEASE RETIRED FROM BUSBAR" above: `.ci/predev.yaml` judges the move in | +| `qa` | promotion target. **The one release build happens here** — the PGO build that ships, tested for real. | ~~full CI + the real-media matrix~~ SUPERSEDED 2026-10-13 by "BUSBAR-RELEASE RETIRED FROM BUSBAR" above: `.ci/dev.yaml` judges the move in | +| `main` | **a push here cuts a release** — tag, GitHub Release, container promotion, `latest` moved. Irreversible. **`main` never compiles:** it tags and publishes the artifacts, by digest, that `qa` built and verified. | ~~release orchestration~~ SUPERSEDED 2026-10-13 by "BUSBAR-RELEASE RETIRED FROM BUSBAR" above: `.ci/qa.yaml` judges the move in; its `on.landed` steps publish | **Never push `dev`, `qa` or `main` by hand.** `predev` and `dev` take changes by pull request only (ruleset; OWNER R3 2026-10-01); `--force-with-lease` is permitted only on your own `lane-*` branch. **Branch protection is on `qa` only** — @@ -3138,21 +3186,21 @@ Nothing is rebuilt between them. **`GetBusbar/busbar`** — the product. `crates/`, `xtask/` (every gate), `scripts/` (including `verify-1.6.0-done.sh`, the definition of done), `conformance/`, `testing/shadow-oracle/`. -**`GetBusbar/busbar-release`** — the engine, nine crates. Being redesigned as one PR state machine (busbar-release docs/ARCHITECTURE.md). +~~**`GetBusbar/busbar-release`** — the engine, nine crates. Being redesigned as one PR state machine (busbar-release docs/ARCHITECTURE.md).~~ SUPERSEDED 2026-10-13 by "BUSBAR-RELEASE RETIRED FROM BUSBAR" above: busbar-release is the tools repo; the rows below for the turnstile, the train, the autoscaler and the CI half of the command line are deleted. | Crate | Job | |---|---| -| `busbar-release-turnstile` | The gate. Runs the batteries and returns a **BOARD** verdict — `ADMIT` or deny. Nothing reaches `dev` without it. | -| `busbar-release-train` | The mover. Fast-forwards `predev` → `dev` → `qa` → `main` in lockstep, refusing unless every precondition holds. | +| `busbar-release-turnstile` | ~~The gate. Runs the batteries and returns a **BOARD** verdict — `ADMIT` or deny. Nothing reaches `dev` without it.~~ DELETE | +| `busbar-release-train` | ~~The mover. Fast-forwards `predev` → `dev` → `qa` → `main` in lockstep, refusing unless every precondition holds.~~ DELETE | | `busbar-release-oracle` | The byte-identity oracle: this build vs the published 1.5.5 binary. | -| `busbar-release-autoscaler` | Runner capacity. Ships the **`busbar-fleet`** binary (`src/bin/busbar-fleet.rs`) — this is the "fleet" tool; there is no separate fleet crate. | +| `busbar-release-autoscaler` | ~~Runner capacity. Ships the **`busbar-fleet`** binary (`src/bin/busbar-fleet.rs`) — this is the "fleet" tool; there is no separate fleet crate.~~ DELETE | | `busbar-release-corpus` | The recorded request corpus the oracle replays. | -| `busbar-release-cli` | The one `busbar-release` command line: ship, dev, watch, status, plugin, and the CI internals under `ci`. | +| `busbar-release-cli` | ~~The one `busbar-release` command line: ship, dev, watch, status, plugin, and the CI internals under `ci`.~~ KEEP only the oracle, soak and plugin verbs | | `busbar-release-github` | The one door to GitHub: batched GraphQL reads, REST writes, shared rate-limit and retry policy. | | `busbar-release-plugin` | The plugin fleet generator: renders `template/` into every `busbar--` repo, with drift check, sync and repin. | | `busbar-release-soak` | The sustained mixed-traffic soak of a built binary against the oracle's mocks, judged row by row. | -Its workflows: `turnstile.yml`, `fleet-control.yml`, `oracle-rerecord.yml`, `docker-autoscaler.yml`, `ci.yml`. +~~Its workflows: `turnstile.yml`, `fleet-control.yml`, `oracle-rerecord.yml`, `docker-autoscaler.yml`, `ci.yml`.~~ SUPERSEDED 2026-10-13 by "BUSBAR-RELEASE RETIRED FROM BUSBAR" above. **The marketing site is observed, never depended on** (owner, 2026-09-06). Nothing in the release path triggers, `needs:` or waits on the site deploy; the site keeps its own trigger. Site checks @@ -3165,7 +3213,7 @@ fully torn down — zero instances, zero volumes, zero NAT gateways, zero elasti both cheaper and the money oracle's home (**#56 wins over #29** — the oracle rides Latchkey, it does not need a dedicated fleet box; `CI_RUNNER_ONDEMAND_FLOOR` is 0, not 2). -**Runner gateway (OWNER 2026-10-10, supersedes the 2026-09-28 capacity order):** every job asks for a logical size (small, medium, large, xlarge), never a vendor label. One table maps each size to a backend (latchkey, ec2 or github), so switching a backend is a one-line change and nothing else breaks. Today every size maps to Latchkey (account cap 80 concurrent jobs); EC2 burst is off. A daily canary job runs on every backend in the table, including the switched-off ones, so a dormant backend is proven before anyone switches to it. #78's spend breaker binds every paid backend. +~~**Runner gateway (OWNER 2026-10-10, supersedes the 2026-09-28 capacity order):** every job asks for a logical size (small, medium, large, xlarge), never a vendor label. One table maps each size to a backend (latchkey, ec2 or github), so switching a backend is a one-line change and nothing else breaks. Today every size maps to Latchkey (account cap 80 concurrent jobs); EC2 burst is off. A daily canary job runs on every backend in the table, including the switched-off ones, so a dormant backend is proven before anyone switches to it. #78's spend breaker binds every paid backend.~~ SUPERSEDED 2026-10-13 by "BUSBAR-RELEASE RETIRED FROM BUSBAR" above: ModernCI places each check on its backends. Agent compute is Latchkey only, through one shared job budget (`1.6.0-TODO.md`, THE RULES 6.1-6.3); the owner has ruled no extra build capacity (2026-09-29). EC2 is allowed for the performance phase (§11.9). @@ -3270,7 +3318,7 @@ now fixed, one was a misdiagnosis, and one dissolves on the first promotion.** C ## Size is not a CI check (owner 2026-10-02) -The hop (`promote` → `preflight` + `hop`) never denies a PR for size, at any rung. Line counts are +ModernCI ~~The hop (`promote` → `preflight` + `hop`)~~ never denies a PR for size, at any rung. Line counts are not measured in CI at all: the construction gate's `loc-ceilings:*` and `surface-ceiling:*` rows, its `ceiling-rose` / `ceiling-slack` rows, `ship-ready`'s two ceiling rows and the per-cell counts of `kind-isolation:matrix` are deleted, and with them every hand-kept size integer in @@ -3297,33 +3345,36 @@ in the default `pull_request` types. ## THE CONDUCTOR (OWNER 2026-10-11) — one deterministic driver; authors write code, nothing else -> **OWNER 2026-10-11.** *"We have the pieces, not a thing pushing things through the pieces, and +> **SUPERSEDED 2026-10-13 by "BUSBAR-RELEASE RETIRED FROM BUSBAR" above.** The conductor is deleted: ModernCI is the one program that judges, trains and +> moves busbar's branches. The text below is struck, kept for its history. + +> ~~**OWNER 2026-10-11.** *"We have the pieces, not a thing pushing things through the pieces, and > that can't be an agent."* *"I wake up one day, never use agents again, replace them with human Bob, > and the system still works. Bob codes, runs one command, and it does its thing and tells Bob (or > the agent) if they need to do something else."* *"Every second of waste is $ burnt — money and > time."* *"The workflow is the workflow"* (no run cap). Supersedes, where they disagree: "How work > lands" item 3 (GitHub's merge queue, OWNER 2026-10-02), "Ship and stop", and every rule that has -> an agent push, prove, retry, land, promote, or be spawned by the pipeline. +> an agent push, prove, retry, land, promote, or be spawned by the pipeline.~~ -**Why (measured 2026-10-10/11, busbar-release's 2-hour audit ledger, 9 windows):** Latchkey $442, +~~**Why (measured 2026-10-10/11, busbar-release's 2-hour audit ledger, 9 windows):** Latchkey $442, of which $254 (57%) bought nothing: 166 of 264 merge-group runs were cascades thrown away behind a red group, 211 of 419 PR runs were red (fixes nobody had run), and the proof that would have caught -them cheaply was refused by a run cap. +them cheaply was refused by a run cap.~~ -**The rule.** One program decides; authors only write code. An AUTHOR is whoever writes code — a +~~**The rule.** One program decides; authors only write code. An AUTHOR is whoever writes code — a person or an agent, interchangeably; the pipeline never knows or cares which, and never spawns one. The CONDUCTOR is `busbar-release conductor`, one always-on service (today on the build server, the M3 at `10.1.7.14`; it runs anywhere from one config directory). It is deterministic code: every decision is a rule over measured facts. It updates itself from each green `main` release of -busbar-release; nothing about running it is agent-driven. +busbar-release; nothing about running it is agent-driven.~~ -**The author's whole interface is one command:** `busbar-release submit` from a branch (it replaces +~~**The author's whole interface is one command:** `busbar-release submit` from a branch (it replaces `ship`). The conductor takes it from there and tells the author only when they must act — what is red, with the output, and the one thing to do next (fix and `submit` again; resolve a conflict; owner sign-off needed) — on the terminal, as the PR's one verdict comment, and in `busbar-release -status`. +status`.~~ -**The stations after `submit`.** The conductor owns every move. +~~**The stations after `submit`.** The conductor owns every move.~~ | Station | What happens | Junk it refuses | |---|---|---| @@ -3332,46 +3383,46 @@ status`. | TRAIN | The conductor batches the PRs whose PR check is green onto the predev tip (`train/`, one merge per PR, a conflicting PR goes back to its author) and runs the full plan ONCE on the batch. Green: predev takes the batch and every PR in it lands. Red: the failing rows are attributed to the PRs whose changes reach them and those go back to their authors; the rest re-batch (unattributable: split in half). Only the conductor moves predev. | Speculative per-PR merge groups and their cascades (impossible by construction); a batch rebuilt for PRs that did not change. | | PROMOTE | predev -> dev -> qa -> main on the same engine, each rung judged once, absolutely where the ladder says so. | A promotion run that cannot pass (a rung whose required suite is registered not-run). | -**METER** runs under every station: each proof and CI run records backend, minutes, dollars and -outcome in one ledger; the waste over any window is a `health` row that fails above 10%. +~~**METER** runs under every station: each proof and CI run records backend, minutes, dollars and +outcome in one ledger; the waste over any window is a `health` row that fails above 10%.~~ -**PROVERS are registered backends, like runners.** A prover is a drop-in driver beside the runner +~~**PROVERS are registered backends, like runners.** A prover is a drop-in driver beside the runner gateway's (`Driver`): its name, slots, price per minute, readiness, canary, its own idempotent `up`/`down` (the M3 prover's `up` builds its Linux VM from nothing), and `prove(commit, commands) -> verdict`. Which provers are live, in what order and with how many slots is one table (`PROVERS`), switched with one command after a canary, never by code: today the M3 (an arm64 Linux VM, warm cache), Latchkey as overflow; tomorrow any mix of M3, Latchkey, EC2. The conductor fills the first prover with a free slot. A proof is a filter, never the verdict: the verdict stays CI's, on Linux -x86_64, so a rare arm64/x86_64 difference costs at most one CI run. +x86_64, so a rare arm64/x86_64 difference costs at most one CI run.~~ -**The conductor IS the workflow; GitHub is one forge driver (OWNER 2026-10-11).** *"Conductor is the +~~**The conductor IS the workflow; GitHub is one forge driver (OWNER 2026-10-11).** *"Conductor is the master workflow: what could be a GitHub Actions workflow is the Rust conductor with pluggable infra."* *"We shouldn't be tightly coupled to GitHub; it is a code repo, and if I want to go to -Forgejo I could, mid-work."* So: -- The conductor executes every job of every station itself, on INFRA DRIVERS (the M3's per-job +Forgejo I could, mid-work."* So:~~ +- ~~The conductor executes every job of every station itself, on INFRA DRIVERS (the M3's per-job Linux VMs, the Latchkey CLI, EC2 boxes, GitHub-hosted runners), chosen by one owner command (`busbar-release infra use `, canaried, effective on the next job). Every job asks for a size, including the light orchestration jobs; nothing names a vendor. It stores logs and links them in the PR's one verdict comment, and posts ONE verdict status per PR head that branch - protection requires. -- The code host is a FORGE DRIVER: git push/fetch, pull requests, comments, the verdict status, branch + protection requires.~~ +- ~~The code host is a FORGE DRIVER: git push/fetch, pull requests, comments, the verdict status, branch protection. GitHub is the driver today; Forgejo is the same interface. Nothing in the pipeline depends on a forge-only feature: Actions workflows (replaced by the conductor), the merge queue - (replaced by the train), forge-only APIs. Switching forge is configuration and a mirror push. -- Platform of record: busbar's VERDICT jobs run on x86_64 Linux (the 1.5.5 golden was recorded + (replaced by the train), forge-only APIs. Switching forge is configuration and a mirror push.~~ +- ~~Platform of record: busbar's VERDICT jobs run on x86_64 Linux (the 1.5.5 golden was recorded there) unless a measured run proves arm64 byte-identical; the M3's arm64 VMs take proofs and - busbar-release's own CI. The M3 runs untrusted-safe: one throwaway VM per job. -- OPEN (owner, before step 7 of 1.6.0): release signing without a forge-specific identity (SLSA + busbar-release's own CI. The M3 runs untrusted-safe: one throwaway VM per job.~~ +- ~~OPEN (owner, before step 7 of 1.6.0): release signing without a forge-specific identity (SLSA provenance today assumes GitHub's OIDC token, which exists only inside Actions): our own signing - key, or the chosen forge's CI. + key, or the chosen forge's CI.~~ -**What the conductor replaces** (deleted as each station lands): GitHub's merge queue on predev, +~~**What the conductor replaces** (deleted as each station lands): GitHub's merge queue on predev, `jump`, the merge-group re-check and orphan machinery, the STALE/UPDATE rule, `ship` and `dev` as author tools, and every agent the pipeline spawns (fix, stuck, rebase, shared). The turnstile, the -hop, the ladder, the step cache, autopin and the EC2 runner backend stay as its internals. +hop, the ladder, the step cache, autopin and the EC2 runner backend stay as its internals.~~ -**Interim, until the train is live:** predev's merge queue builds at most 2 groups at once (OWNER -2026-10-11, was 10). +~~**Interim, until the train is live:** predev's merge queue builds at most 2 groups at once (OWNER +2026-10-11, was 10).~~ ## How work lands: the PR flow and the rules every contributor follows @@ -3385,10 +3436,11 @@ branches live on GitHub so CI runs the pipeline). 1. One branch per lane, `lane-`, cut from `origin/predev`. A branch that depends on another unmerged lane says so in its PR body. -2. Changes reach `predev` and `dev` **only by pull request**. The ruleset on both requires the two - jobs of `.github/workflows/promote.yml`, `preflight` and `hop`; no direct push, no force-push, no - deletion. `dev` moves only through the promote engine. -3. predev lands through GitHub's merge queue (OWNER 2026-10-02). The MERGE-GROUP run is the proof of +2. Changes reach `predev` and `dev` **only by pull request**. The ruleset on both requires ~~the two + jobs of `.github/workflows/promote.yml`, `preflight` and `hop`~~ ModernCI's check `ci`; no direct + push, no force-push, no deletion. `dev` moves only through ~~the promote engine~~ ModernCI's + `predev` → `dev` move (`.ci/predev.yaml`). +3. ~~predev lands through GitHub's merge queue (OWNER 2026-10-02). The MERGE-GROUP run is the proof of record: the full plan (every gate, every build/test step, the money oracle forced, conformance produced at judge time and judged absolutely per #68) on the queue's merge commit, judged "no worse than base" against the base the queue built it on; its report becomes the base of the commit it @@ -3398,19 +3450,19 @@ branches live on GitHub so CI runs the pipeline). PR merged onto the base tip, with no oracle and no conformance; on ONE runner when that is estimated within 20 minutes, else as the merge group's shard matrix ("Verification runs once per input", OWNER-LOCKED 2026-10-06). Neither may add a red. A local Latchkey - run is for iteration only. + run is for iteration only.~~ SUPERSEDED 2026-10-13 by "BUSBAR-RELEASE RETIRED FROM BUSBAR" above: ModernCI judges every pull request on its branch file and lands it with its own merge train; `dev`, `qa` and `main` move by ModernCI's promotion. 4. A `$` (money-touching) change is its own PR, never bundled with anything else. 5. On merge the branch is deleted (OWNER 2026-10-01, BRANCH LIFETIME: "as soon as merged into predev get rid of the branch"). -**Ship and stop** (ARCHITECT 2026-10-02, supersedes every polling instruction). When a slice is +~~**Ship and stop** (ARCHITECT 2026-10-02, supersedes every polling instruction). When a slice is ready, run one command from the lane's worktree: `busbar-release ship "" [--body ]` (the release CLI, `bin/busbar-release` in a clone of GetBusbar/busbar-release; OWNER 2026-10-02: every release tool lives there, and `cargo xtask ship` is gone). It refuses any branch that is not `lane-*`, merges `origin/predev` in, runs the local pre-flight, commits what the pre-flight changed (tracked files only), pushes, opens or reuses the PR into `predev` and turns on auto-merge. Then STOP: no polling of CI, PRs or Latchkey, no sleep loops. A red or conflicted PR comes back to the owning -lane as a new task; fix it, ship again, stop again. +lane as a new task; fix it, ship again, stop again.~~ SUPERSEDED 2026-10-13 by "BUSBAR-RELEASE RETIRED FROM BUSBAR" above: the author pushes the branch and opens the pull request; ModernCI does the rest (`modernci done`). **Never bless a golden or a ratchet.** A red is fixed forward with a root cause; nothing is waived and nothing is blessed (OWNER 2026-10-01, PREDEV IS THE CONVERGENCE BRANCH). Customer-visible diff --git a/qa/DESIGN-BINDINGS.md b/qa/DESIGN-BINDINGS.md index 3daf253448..8c1bfdbbf4 100644 --- a/qa/DESIGN-BINDINGS.md +++ b/qa/DESIGN-BINDINGS.md @@ -94,7 +94,7 @@ The three words are not interchangeable: | PB-65 | egress auth wire behaviour | mapped | PASS | | test: `test_auth_headers_valid_key_emits_x_goog_api_key`
test: `egress_scheme_is_the_declared_credential_family_table`
test: `crates/busbar/src/root/tests/declared_credentials.rs::a_declared_static_header_follows_the_credential_verbatim`
test: `crates/busbar/src/root/tests/declared_credentials.rs::each_declared_scheme_presents_what_its_dialect_builder_wrote`
test: `anthropic_egress_carries_exactly_the_declared_headers`
test: `every_egress_request_declares_the_pinned_api_version`
test: `with_no_static_header_declared_the_version_header_is_absent`
oracle-cell: `egress.auth\|cred-identity\|anthropic`
oracle-cell: `egress.auth\|sigv4\|session-token`
test: `test_bedrock_sigv4_sign_request_structure`
test: `test_bedrock_sigv4_session_token`
test: `a_misconfigured_or_unsendable_signing_credential_signs_nothing` | | PB-66 | request and response headers | mapped | PASS | | test: `same_dialect_anthropic_forwards_every_client_header`
test: `test_egress_accept_matches_native_sdk`
test: `test_egress_ua_versions_are_pinned_and_present`
test: `test_ingress_stream_content_type_by_protocol`
test: `test_bedrock_ingress_success_carries_amzn_request_id`
test: `test_forward_once_bedrock_error_relays_amzn_headers`
test: `test_anthropic_ingress_success_carries_request_id_header`
test: `test_anthropic_same_proto_error_relays_upstream_request_id_verbatim_once`
test: `test_synth_anthropic_request_id_is_well_formed` | | PB-67 | per-dialect error mapping | mapped | PASS | | test: `test_cross_protocol_error_kind_mapping`
test: `test_shape_cross_protocol_error_auth_kinds`
test: `test_ingress_error_bedrock_amzn_headers`
test: `test_vendor_auth_failure_message_is_plausible_per_proto`
test: `test_bedrock_ingress_wrong_token_is_403_native_envelope`
test: `error_kind_to_bedrock_type_covers_ingress_emitted_kinds`
test: `test_openai_classify`
test: `write_error_kind_vocabulary_mapping` | -| PB-68 | network guard | mapped | PASS | | test: `test_ssrf_blocks_metadata_denylist_by_default`
test: `test_ssrf_blocked_returns_exact_host_string`
test: `test_reject_cidr_metadata_entries`
test: `test_global_allow_overrides_blocked_metadata_hosts`
test: `test_allow_all_metadata_beats_nonempty_blocked_list`
test: `test_ssrf_allows_private_and_loopback_by_default`
test: `test_validate_rejects_non_https_base_url`
test: `test_validate_token_url_ssrf_and_scheme`
test: `crates/busbar-kernel/src/tests/net_guard_tests.rs::the_shared_internal_predicate_covers_every_range_any_plane_ever_checked` | +| PB-68 | network guard | mapped | PASS | | test: `test_ssrf_blocks_metadata_denylist_by_default`
test: `test_ssrf_blocked_returns_exact_host_string`
test: `test_reject_cidr_metadata_entries`
test: `test_global_allow_overrides_blocked_metadata_hosts`
test: `test_allow_all_metadata_beats_nonempty_blocked_list`
test: `test_ssrf_allows_private_and_loopback_by_default`
test: `test_validate_rejects_non_https_base_url`
test: `test_validate_token_url_ssrf_and_scheme`
test: `crates/busbar-kernel-egress/src/trust/tests/net_tests.rs::the_shared_internal_predicate_covers_every_range_any_plane_ever_checked` | | PB-69 | ingress server posture | mapped | PASS | | test: `server_posture_matches_the_1_5_5_defaults` | | PB-70 | scrape shape | mapped | PASS | | oracle-cell: `ops.scrape\|metrics\|key`
test: `a_1_5_5_shaped_config_exposes_no_plane_series_with_every_plane_compiled_in`
test: `no_ledger_series_and_no_keyset_lines_without_data_dir`
oracle-cell: `ops.scrape\|metrics\|no-ledger-series` | | PB-71 | documented behaviour | mapped | PASS | | oracle-family: `documented` | diff --git a/qa/abi-freeze.toml b/qa/abi-freeze.toml index 1da7dbca16..f96bb03009 100644 --- a/qa/abi-freeze.toml +++ b/qa/abi-freeze.toml @@ -7,7 +7,7 @@ [[frozen]] dir = "crates/busbar-contract/src/abi/hot" -lines = "6224" +lines = "6100" [[frozen]] dir = "crates/busbar-contract/src/abi/cold" diff --git a/qa/contract-stateless.toml b/qa/contract-stateless.toml index 7669df3bec..e07d734f2b 100644 --- a/qa/contract-stateless.toml +++ b/qa/contract-stateless.toml @@ -3,11 +3,6 @@ # row is one the tree carries today. Strike a row in the commit that drains it; # never add one. Regenerate with `cargo xtask gate contract-stateless --write`. -[[finding]] -rule = "contract-stateless:once-cell" -file = "crates/busbar-contract/src/abi/hot/services.rs" -item = "OnceLock" - [[finding]] rule = "contract-stateless:once-cell" file = "crates/busbar-contract/src/codec.rs" @@ -43,16 +38,6 @@ rule = "contract-stateless:static" file = "crates/busbar-contract/src/abi/hot/services.rs" item = "ARMED_SIZE" -[[finding]] -rule = "contract-stateless:static" -file = "crates/busbar-contract/src/abi/hot/services.rs" -item = "FILLING" - -[[finding]] -rule = "contract-stateless:static" -file = "crates/busbar-contract/src/abi/hot/services.rs" -item = "KEPT" - # The door macro's per-thread call-capture slot (plugin logging, #85), expanded in each PLUGIN's own # crate: one slot per plugin image, never state shared by host and plugins. Owner 2026-09-28. [[finding]] @@ -170,16 +155,6 @@ rule = "contract-stateless:static" file = "crates/busbar-contract/src/codec.rs" item = "TRANSLATE_CAP_READER" -[[finding]] -rule = "contract-stateless:static" -file = "crates/busbar-contract/src/codec.rs" -item = "USAGE_TAP_FAULT_LATCH" - -[[finding]] -rule = "contract-stateless:static" -file = "crates/busbar-contract/src/codec.rs" -item = "USAGE_TAP_FAULT_REPORTER" - [[finding]] rule = "contract-stateless:static" file = "crates/busbar-contract/src/codec.rs" diff --git a/qa/design-bindings.json b/qa/design-bindings.json index 40d7372956..0e95c24696 100644 --- a/qa/design-bindings.json +++ b/qa/design-bindings.json @@ -3287,14 +3287,14 @@ }, { "kind": "test", - "ref": "crates/busbar-kernel/src/tests/net_guard_tests.rs::the_shared_internal_predicate_covers_every_range_any_plane_ever_checked", + "ref": "crates/busbar-kernel-egress/src/trust/tests/net_tests.rs::the_shared_internal_predicate_covers_every_range_any_plane_ever_checked", "proves": "one shared predicate across planes", "source": "curated", "status": "mapped" } ], "verdict": "PASS", - "verdict_detail": "test:test_ssrf_blocks_metadata_denylist_by_default, test:test_ssrf_blocked_returns_exact_host_string, test:test_reject_cidr_metadata_entries, test:test_global_allow_overrides_blocked_metadata_hosts, test:test_allow_all_metadata_beats_nonempty_blocked_list, test:test_ssrf_allows_private_and_loopback_by_default, test:test_validate_rejects_non_https_base_url, test:test_validate_token_url_ssrf_and_scheme, test:crates/busbar-kernel/src/tests/net_guard_tests.rs::the_shared_internal_predicate_covers_every_range_any_plane_ever_checked" + "verdict_detail": "test:test_ssrf_blocks_metadata_denylist_by_default, test:test_ssrf_blocked_returns_exact_host_string, test:test_reject_cidr_metadata_entries, test:test_global_allow_overrides_blocked_metadata_hosts, test:test_allow_all_metadata_beats_nonempty_blocked_list, test:test_ssrf_allows_private_and_loopback_by_default, test:test_validate_rejects_non_https_base_url, test:test_validate_token_url_ssrf_and_scheme, test:crates/busbar-kernel-egress/src/trust/tests/net_tests.rs::the_shared_internal_predicate_covers_every_range_any_plane_ever_checked" }, { "id": "PB-69", diff --git a/qa/kind-isolation.toml b/qa/kind-isolation.toml index 9af9420263..a5898f5ce4 100644 --- a/qa/kind-isolation.toml +++ b/qa/kind-isolation.toml @@ -828,17 +828,6 @@ cite = "BUSBAR-1.6.0.md THE DESIGN, §1 — kernel -> plugin-tooling is a gra why = "busbar-kernel names busbar-plugin-loader: declared normal in the shipped graph, 1 declaration(s) at this commit. The count is TODAY'S MEASUREMENT, exact in both directions — above it is the landing that grew the coupling, below it is stale slack that was not drained on the commit that drained the edge." drain = "none: BUSBAR-1.6.0.md grants this class. Draining it would be an architecture change and not a landing — what this row holds is the NUMBER, so a second declaration of a granted edge is still a landing that has to say why." -[[dep]] -from = "busbar-kernel" -to = "busbar-store-memory" -half = "shipped" -count = "1" -verdict = "not-allowed" -cite = "ARCHITECT K5/K5b/K5c/K5d rulings (b17f0a2c1, 0ade205df): the composition root links the build's default store as a row of its linked tables (roster def 1); the kernel names neither crate in production. `kernel -> store` is in neither ARCHITECTURE_ALLOWED nor ARCHITECTURE_TCB, so the verdict stays the one the architecture implies; the row holds a TEST-SUPPORT stand-in edge, not a shipped coupling." -why = "busbar-kernel names busbar-store-memory as `fixture-store`: declared normal, optional in the shipped graph, enabled ONLY by the kernel's `test-support` feature (`dep:fixture-store`) — a test build has no root, so this fixture stands in for the row the root links (`preflight::install_linked_rows`); no kernel source names the plugin, and no default or shipped feature turns the edge on. 1 declaration(s) at this commit. The count is TODAY'S MEASUREMENT, exact in both directions." -drain = "delete the `fixture-store` stand-in from busbar-kernel's Cargo.toml once the kernel's own tests take their linked rows from a contract-only fixture; the shipped edge is the root's (busbar -> busbar-store-memory)." - - [[dep]] from = "busbar-a2a" to = "busbar-contract" @@ -1787,8 +1776,8 @@ cite = "measured on predev f830faa691 (ARCHITECT 2026-10-07, Law 9); the drain [[law0]] crate = "busbar-kernel" axis = "instance:secret" -count = "115" -cite = "measured on predev f830faa691 (ARCHITECT 2026-10-07, Law 9); the drain is THE DESIGN §1 / DoD item 2: core names no instance. -2 for R-FIX3 (the kernel's plugin tests move onto in-crate doubles)" +count = "114" +cite = "measured on predev f830faa691 (ARCHITECT 2026-10-07, Law 9); the drain is THE DESIGN §1 / DoD item 2: core names no instance. -2 for R-FIX3 (the kernel's plugin tests move onto in-crate doubles); lowered 115 -> 114 by the deletion of the kernel's dead ask-state seal (audit K2-H3), the gate's measure on this branch merged with predev 1617e9f019" [[law0]] crate = "busbar-kernel" @@ -1799,8 +1788,8 @@ cite = "measured on predev f830faa691 (ARCHITECT 2026-10-07, Law 9); the drain [[law0]] crate = "busbar-kernel" axis = "plane" -count = "1132" -cite = "measured on predev f830faa691 (ARCHITECT 2026-10-07, Law 9); the drain is THE DESIGN §1 / DoD item 2: core names no instance; LOWERED 1193 -> 1175, predev 9c0a394771's measure: the drain landed without giving its row back (Law 9: the ceiling ratchets down); LOWERED 1175 -> 1173, the merge-base d7929b661d's measure, which GetBusbar/busbar#570 holds (its scrape double spells no plane alias) (Law 9: the ceiling ratchets down to today's number); LOWERED 1173 -> 1172, the gate's measure on this branch merged with predev 1617e9f019: Q128 A4's plane_driver/gated.rs drain; REBALANCED 1172 -> 1168 (Q128, PR #686 architect ruling, BUSBAR-1.6.0.md l.475-483): the cross-plane cells moved verbatim into the composition root's crates/busbar/tests/, which takes the same 4 hits (busbar × plane 822 -> 826); LOWERED 1168 -> 1141 by row 157: the hits drained with the deleted HOT trust and guard slots (plane_host/trust.rs, plane_host/guard.rs); LOWERED 1141 -> 1132 by row 156: the hits drained with the deleted kernel call log (calllog.rs, plane/calllog.rs)" +count = "1122" +cite = "measured on predev f830faa691 (ARCHITECT 2026-10-07, Law 9); the drain is THE DESIGN §1 / DoD item 2: core names no instance; LOWERED 1193 -> 1175, predev 9c0a394771's measure: the drain landed without giving its row back (Law 9: the ceiling ratchets down); LOWERED 1175 -> 1173, the merge-base d7929b661d's measure, which GetBusbar/busbar#570 holds (its scrape double spells no plane alias) (Law 9: the ceiling ratchets down to today's number); LOWERED 1173 -> 1172, the gate's measure on this branch merged with predev 1617e9f019: Q128 A4's plane_driver/gated.rs drain; REBALANCED 1172 -> 1168 (Q128, PR #686 architect ruling, BUSBAR-1.6.0.md l.475-483): the cross-plane cells moved verbatim into the composition root's crates/busbar/tests/, which takes the same 4 hits (busbar × plane 822 -> 826); LOWERED 1168 -> 1141 by row 157: the hits drained with the deleted HOT trust and guard slots (plane_host/trust.rs, plane_host/guard.rs); LOWERED 1141 -> 1132 by row 156: the hits drained with the deleted kernel call log (calllog.rs, plane/calllog.rs); LOWERED 1132 -> 1128 by this PR: the deletion of the kernel's dead ask-state seal (audit K2-H3) removes four hits, its two `crate::mcp::roots` doc links and its two `\\x6dcp` seal domains; LOWERED 1128 -> 1122 by status row 130 E2: net_guard.rs and its two suites deleted (six plane hits, the gate's measure on the E2 candidate)" [[law0]] crate = "busbar-kernel" @@ -1811,8 +1800,8 @@ cite = "measured on predev f830faa691 (ARCHITECT 2026-10-07, Law 9); the drain [[law0]] crate = "busbar-kernel" axis = "store" -count = "4" -cite = "measured on predev f830faa691 (ARCHITECT 2026-10-07, Law 9); the drain is THE DESIGN §1 / DoD item 2: core names no instance" +count = "1" +cite = "measured on predev f830faa691 (ARCHITECT 2026-10-07, Law 9); the drain is THE DESIGN §1 / DoD item 2: core names no instance; LOWERED 4 -> 1 by row 88 (kernel -> store edge): the kernel names no store crate, its tests run on the in-crate store double, and the three test comments that cited the plugin by package name now cite the double (audit/tests/journal_tests.rs, plane/tests/demotion_tests.rs, plane_host/tests/journal_tests.rs); the one left is config/sections.rs's 1.5.x `GOVERNANCE_STORE_MEMORY` citation" [[law0]] crate = "busbar-kernel" diff --git a/scripts/verify-1.6.0-done.sh b/scripts/verify-1.6.0-done.sh index 9ac63f162d..dabafa8ca6 100755 --- a/scripts/verify-1.6.0-done.sh +++ b/scripts/verify-1.6.0-done.sh @@ -808,17 +808,19 @@ print("golden whole: %d PASS rows = meta recorded %d, every one with its cell fi PY } -# The engine every PARITY step judges with: bin/oracle fetches busbar-release at the pinned ref into -# target/oracle/rust//checkout and builds it (`tool-dir` is the cheapest subcommand that resolves -# it). Fails naming the two ways to provide it, so a missing judge reads as that, not as a red cell. +# The engine every PARITY step judges with: on Linux bin/oracle fetches busbar-release's published +# busbar-oracle for the pinned ref and runs it only at the pin's sha256 (elsewhere it builds the pinned +# source; `tool-dir` is the cheapest subcommand that resolves it). Fails naming how to provide it, so +# a missing judge reads as that, not as a red cell. parity_resolve_judge() { # $1 = the pinned busbar-release ref local ref="${1:-}" [ -n "$ref" ] || { echo "testing/shadow-oracle/oracle-rust.pin names no release_ref — there is no judge to build"; return 1; } if ./bin/oracle tool-dir >/dev/null 2>/tmp/done-parity-judge.$$; then rm -f /tmp/done-parity-judge.$$; return 0; fi tail -6 /tmp/done-parity-judge.$$; rm -f /tmp/done-parity-judge.$$ echo "the judge could not be fetched or built. busbar-release is private: give the run a token" - echo "(BUSBAR_RELEASE_TOKEN, GH_TOKEN or GITHUB_TOKEN; bin/oracle never stores it), or place a git" - echo "checkout holding commit $ref at target/oracle/rust/$ref/checkout before running" + echo "(BUSBAR_RELEASE_TOKEN, GH_TOKEN or GITHUB_TOKEN; bin/oracle never stores it). On Linux the" + echo "judge is dev-bin's busbar-oracle for commit $ref at the pin's sha256 (no line: no judge);" + echo "elsewhere, place a git checkout holding $ref at target/oracle/rust/$ref/checkout" return 1 } @@ -1437,7 +1439,7 @@ elif [ -x bin/oracle ]; then # cannot be obtained (busbar-release is private: a runner with no token and no checkout of the # pinned commit), say so here, by name, instead of as three unrelated-looking reds below. PARITY_JUDGE_REF="$(awk -F= '$1=="release_ref"{print $2; exit}' testing/shadow-oracle/oracle-rust.pin 2>/dev/null)" - step "the pinned judge (busbar-release ${PARITY_JUDGE_REF:-}) is fetched and built from its source" \ + step "the pinned judge (busbar-release ${PARITY_JUDGE_REF:-}) is fetched at the pin's sha256" \ parity_resolve_judge "$PARITY_JUDGE_REF" step "replay-selftest (the differ can see a diff)" ./bin/oracle replay-selftest # cells.json IS the owed set — the recorder and the replayer both iterate it, and every count in diff --git a/testing/shadow-oracle/accepted-differences.json b/testing/shadow-oracle/accepted-differences.json index 413c090ce6..4832169bb9 100644 --- a/testing/shadow-oracle/accepted-differences.json +++ b/testing/shadow-oracle/accepted-differences.json @@ -2944,6 +2944,81 @@ "by": "2026-12-31" }, "rationale": "DECISION: Part 2 #34, every plugin instance is named busbar--, so `busbar-webrequest` ships as `busbar-hook-webrequest`. CELL: boot.warning|BOOT-W22|boot. BYTES MOVED (golden -> candidate): body.text trust WARN line `plugin=busbar-webrequest file=busbar-webrequest-` -> `plugin=busbar-hook-webrequest file=busbar-hook-webrequest-`. The transform rewrites only those name bytes back to the 1.5.5 name." + }, + { + "id": "FID-CHAT translated Chat answers and requests carry the Chat members the IR maps", + "kind": "improvement", + "changelog": "A translated OpenAI Chat answer carries a model refusal in `message.refusal` / `delta.refusal` and the serving `service_tier`, an image answer omits `created` when the upstream sent none, and a translated request keeps a `developer` system prompt in the `developer` role.", + "by": "owner (Matthew Jackson), 2026-10-02: THE LLM DIALECT FIDELITY RULE (docs/design/BUSBAR-1.6.0.md:4768-4774)", + "cells": "^llm\\.fidelity\\|openai\\|(refusal|service-tier|image-created|developer-role)$", + "expected_cells": 0, + "classes": [ + "body" + ], + "rationale": "Pre-approved by BUSBAR-1.6.0.md:4769 (\"Every LLM dialect covers 100% of its own protocol ... Across dialects, we translate what maps and drop what doesn't\"), :4771 (TRANSLATE: a pair translates every slot both sides map) and :4774 (\"Byte moves vs 1.5.5 that follow from this rule are pre-approved; each gets a register entry citing it. Billing never moves.\"). Each move is a Chat member the IR maps, written where 1.5.5 substituted or dropped it; none moves a billed count (the ledger reads the egress reader, never these bytes). (1) Refusal: an IR refusal stop's text is written to the Chat `refusal` member, `content: null` (served crates/busbar-plane-llm/src/codec/openai_chat/writer.rs:1137-1147 buffered, :729-733 stream); 1.5.5 wrote the text into `content` (v1.5.5 crates/busbar/src/proto/openai_chat/writer.rs:826-833). (2) `service_tier`: the tier that served the answer (served writer.rs:890-898 stream, :1338-1346 buffered); 1.5.5 wrote none. (3) Image `created`: emitted only when the upstream sent one (served crates/busbar-plane-llm/src/codec/openai_chat/handler.rs:734-738); 1.5.5 wrote `r.created.unwrap_or(0)`, a 1970 timestamp (v1.5.5 crates/busbar/src/handlers/openai.rs:782). (4) `developer` role: a Responses/Chat `developer` system prompt reaches a Chat upstream as `role: developer` (served writer.rs:75-90, IR-14); 1.5.5 wrote `role: system` (v1.5.5 crates/busbar/src/proto/openai_chat/writer.rs:28). No oracle cell sends any of these to a Chat door or upstream: the cells are OWED, `cells` names them and expected_cells is 0. The premise probes the existing Chat-door llm cell.", + "premise": { + "cell": "llm|openai|anthropic|request|ok", + "status": 200 + }, + "signoff": { + "who": "owner (Matthew Jackson)", + "when": "2026-10-02", + "ruling": "THE LLM DIALECT FIDELITY RULE (owner 2026-10-02, standing, no per-item sign-off), docs/design/BUSBAR-1.6.0.md:4768-4774" + }, + "expires": { + "baseline": "1.5.5", + "by": "2026-12-31" + } + }, + { + "id": "FID-RESPONSES translated Responses requests and answers carry the Responses members the IR maps", + "kind": "improvement", + "changelog": "A translated OpenAI Responses request keeps a `developer` system prompt as a `developer` input item and sends a JSON tool result as the `function_call_output` text, and a translated Responses stream ends at `response.failed` and names the serving `service_tier`.", + "by": "owner (Matthew Jackson), 2026-10-02: THE LLM DIALECT FIDELITY RULE (docs/design/BUSBAR-1.6.0.md:4768-4774)", + "cells": "^llm\\.fidelity\\|responses\\|(developer-item|json-tool-output|failed-terminal|service-tier)$", + "expected_cells": 0, + "classes": [ + "body" + ], + "rationale": "Pre-approved by BUSBAR-1.6.0.md:4769 (\"Every LLM dialect covers 100% of its own protocol ... Across dialects, we translate what maps and drop what doesn't\"), :4771 (TRANSLATE: a pair translates every slot both sides map) and :4774 (\"Byte moves vs 1.5.5 that follow from this rule are pre-approved; each gets a register entry citing it. Billing never moves.\"). Each move is the Responses protocol's own shape for a member the IR maps; none moves a billed count. (1) `developer` system prompt: written as a leading `{role: developer}` input item (served crates/busbar-plane-llm/src/codec/openai_responses/writer.rs:101-116, IR-14); 1.5.5 always wrote `instructions` (v1.5.5 crates/busbar/src/proto/openai_responses/writer.rs:22). (2) A JSON tool result (a Bedrock `{json}` block) is serialized into `function_call_output.output` (served writer.rs:1989-1997); 1.5.5 dropped it (v1.5.5 writer.rs:140, :265 wrote text parts only). (3) After `response.failed` the writer writes nothing (served writer.rs:1320-1322): `response.failed` is the Responses terminal event, and 1.5.5 wrote a `response.completed` after it. (4) `service_tier` on the response object (served writer.rs:1425-1429 stream, :1758-1766 buffered); 1.5.5 wrote none. These four stream/request bytes previously passed only through the blanket `body` class of S-6/S-15, whose rationales do not name them. No oracle cell exercises them: the cells are OWED, `cells` names them and expected_cells is 0. The premise probes the existing Responses-door llm cell.", + "premise": { + "cell": "llm|responses|anthropic|request|ok", + "status": 200 + }, + "signoff": { + "who": "owner (Matthew Jackson)", + "when": "2026-10-02", + "ruling": "THE LLM DIALECT FIDELITY RULE (owner 2026-10-02, standing, no per-item sign-off), docs/design/BUSBAR-1.6.0.md:4768-4774" + }, + "expires": { + "baseline": "1.5.5", + "by": "2026-12-31" + } + }, + { + "id": "FID-GEMINI translated Gemini requests and answers carry the Gemini members the IR maps", + "kind": "improvement", + "changelog": "A translated Gemini request or answer carries `functionCall.id` / `functionResponse.id`, a failed tool result as `functionResponse.response.error`, a `fileData.mimeType`, the caller's transcription prompt and temperature, and reads Vertex `createTime`, a `functionResponse.id` as the call id, and the image content-policy stops as safety stops.", + "by": "owner (Matthew Jackson), 2026-10-02: THE LLM DIALECT FIDELITY RULE (docs/design/BUSBAR-1.6.0.md:4768-4774)", + "cells": "^llm\\.fidelity\\|gemini\\|(call-ids|response-error|filedata-mime|transcription-prompt|create-time|image-policy-stops)$", + "expected_cells": 0, + "classes": [ + "body" + ], + "rationale": "Pre-approved by BUSBAR-1.6.0.md:4769 (\"Every LLM dialect covers 100% of its own protocol ... Across dialects, we translate what maps and drop what doesn't\"), :4771 (TRANSLATE: a pair translates every slot both sides map) and :4774 (\"Byte moves vs 1.5.5 that follow from this rule are pre-approved; each gets a register entry citing it. Billing never moves.\"). Each is a member of Gemini's published GenerateContent shape the IR maps; none moves a billed count. (1) `functionCall.id` and `functionResponse.id` are written (served crates/busbar-plane-llm/src/codec/gemini/writer.rs:219-221, :325-327, GEM-08); 1.5.5 wrote `{name, args}` / `{name, response}` only (v1.5.5 crates/busbar/src/proto/gemini/writer.rs:199). (2) A failed tool result is `functionResponse.response.error` (served writer.rs:305-314, GEM-05); 1.5.5 wrote it as an ordinary response. (3) A URL image is `fileData{fileUri, mimeType}` (served writer.rs:350); 1.5.5 wrote `fileData{fileUri}` (v1.5.5 writer.rs:205). (4) A transcription request carries the caller's `prompt` as its own text part and `temperature` in `generationConfig` (served crates/busbar-plane-llm/src/codec/gemini/handler.rs:196-210); 1.5.5 sent only its fixed instruction (v1.5.5 crates/busbar/src/handlers/gemini.rs:205). (5) Reader: Vertex `createTime` becomes the answer's `created` (served crates/busbar-plane-llm/src/codec/gemini/reader.rs:1552-1577); 1.5.5 set `created: None` (v1.5.5 crates/busbar/src/proto/gemini/reader.rs:1138). (6) Reader: a `functionResponse` pairs to its call's id (served reader.rs:438-443, GEM-01); 1.5.5 used the function name as `tool_use_id` (v1.5.5 reader.rs:362-365). (7) Reader: `IMAGE_PROHIBITED_CONTENT` / `IMAGE_RECITATION` stops and an `IMAGE_SAFETY` prompt block read as safety stops (served crates/busbar-plane-llm/src/codec/gemini/mod.rs:1556-1557, :1581), siblings of the `IMAGE_SAFETY` stop 1.5.5 already read as safety (v1.5.5 crates/busbar/src/proto/gemini/mod.rs:733). Not covered by this entry, and restored to 1.5.5 instead: a refusal is written `OTHER` again, not `SAFETY`, and an Imagen `:predict` answer carries no `usageMetadata`. No oracle cell exercises these: the cells are OWED, `cells` names them and expected_cells is 0. The premise probes the existing Gemini-door llm cell.", + "premise": { + "cell": "llm|gemini|anthropic|request|ok", + "status": 200 + }, + "signoff": { + "who": "owner (Matthew Jackson)", + "when": "2026-10-02", + "ruling": "THE LLM DIALECT FIDELITY RULE (owner 2026-10-02, standing, no per-item sign-off), docs/design/BUSBAR-1.6.0.md:4768-4774" + }, + "expires": { + "baseline": "1.5.5", + "by": "2026-12-31" + } } ] } diff --git a/xtask/src/conformance_record/jev.rs b/xtask/src/conformance_record/jev.rs index b265b9fc52..b3445b0568 100644 --- a/xtask/src/conformance_record/jev.rs +++ b/xtask/src/conformance_record/jev.rs @@ -6,8 +6,8 @@ //! * BATTERY — `cargo test -p busbar-plane-decisions`: the codec, the fixtures, the PII witness, //! purity and invariance, and the both-ways door. It says the plane reads and writes jev. It //! cannot say busbar SERVES jev, and a battery that selected zero tests says nothing at all. -//! * SERVED — a busbar built from this checkout, booted with exactly one `decisions.models` entry -//! (the one shape that mounts `POST /v1/systemone`, `busbar-plane-decisions/src/driven.rs`), +//! * SERVED — a busbar built from this checkout, booted with one `decisions.models` entry (so a +//! request naming no model routes over it, the default, `busbar-plane-decisions/src/driven.rs`), //! whose provider is a far end this rig owns ([`super::upstream::FarEnd`]). The leg judges the //! SERVED BYTES from both ends of the relay: //! @@ -45,8 +45,31 @@ //! The 1.6.0 node keeps its rows at bucket-day width (no lane, no provider), so there the jev //! lane's row is the rig's caller's own bucket ([`judge_jev_ledger`]). //! The subject's own card prices the `decision` class at 1 micro-unit per unit -//! (`decisions.rate_card..units.decision: 1`, in the rig's generated config only), so the -//! lane's `priced_micros` IS the unit count: 42. +//! (`decisions.rate_card..units.decision: 1`, in the rig's generated config only) and the +//! two token classes the plane also declares at 0 (THE DESIGN §7: "all three required, 0 = +//! free"), so the lane's `priced_micros` IS the unit count: 42. +//! +//! THE MODEL-RESOLUTION CHECKS (THE DESIGN §2, the decisions bullet; DECISIONS D8b) run on a second +//! boot whose `decisions.models` holds two models, [`MODEL`] and [`UPSTREAM_MODEL`] (its +//! `upstream_model` set to [`UPSTREAM_NAME`]), on the same far end: +//! +//! | check | what a correct busbar does | +//! |---|---| +//! | `models.unnamed-400` | two models and none named: `400` in jev's shape, no far-end hop | +//! | `models.unknown-404` | a model no entry has: `404` in jev's shape, no far-end hop | +//! | `models.upstream-model` | the second model named: served, and the far end receives the caller's bytes with the top-level `model` value alone spliced to its `upstream_model` | +//! +//! THE `nanojev` CHECKS (THE DESIGN §2: "NanoJev is the one, dialect `nanojev`: `/api/evaluate` is +//! its second exact claim ... it meters the upstream's own billing unit, counted from the answer +//! already read (`execution.states`; an answer with no count refuses loudly ...), with no extra +//! egress") run on a third boot whose `decisions.models` holds [`MODEL`] on the jev provider and +//! [`NANOJEV_MODEL`] on a provider whose protocol is `nanojev`: +//! +//! | check | what a correct busbar does | +//! |---|---| +//! | `nanojev.served` | `POST /api/evaluate` is served: the far end receives `POST /api/evaluate` with the caller's bytes, the caller its 200 bytes, over one dial | +//! | `nanojev.wrong-dialect-404` | the jev model named on `/api/evaluate`: `404` in jev's shape, no far-end hop | +//! | `nanojev.no-count-refused` | a success with no `execution.states`: a 5xx in jev's shape, never the far end's bytes, over one dial | //! //! The rules, in order: battery red or empty ⇒ `fail`; no subject binary ⇒ `not-run` (nothing was //! booted); a subject that does not boot ⇒ `fail`; `POST /v1/systemone` answering the absence code @@ -67,6 +90,20 @@ use super::Outcome; const MODEL: &str = "jev-1"; const PROVIDER: &str = "typesafe"; const PATH: &str = "/v1/systemone"; +/// The second boot's second model, and the `upstream_model` it names. +pub const UPSTREAM_MODEL: &str = "jev-2"; +pub const UPSTREAM_NAME: &str = "jev-upstream-2"; +/// The `nanojev` boot's provider and model, and its dialect's one path. +pub const NANOJEV_PROVIDER: &str = "nanoprov"; +pub const NANOJEV_MODEL: &str = "nano-1"; +pub const PATH_EVALUATE: &str = "/api/evaluate"; +/// A NanoJev request, odd spacing kept, naming no model (the one `nanojev` model is its default). +pub const NANOJEV_REQUEST: &[u8] = b"{ \"input\" : {\"case\":\"conformance\"} }"; +/// A NanoJev success, its billing unit stated. +pub const NANOJEV_SUCCESS: &[u8] = + b"{\"result\":{\"decision\":\"approve\"}, \"execution\" : {\"states\":3}}"; +/// A NanoJev success with no billing unit: the plane refuses it loudly. +pub const NANOJEV_NO_COUNT: &[u8] = b"{\"result\":{\"decision\":\"approve\"}}"; /// The header outside the deny-set the caller sends and the far end must receive unchanged. const PROBE_HEADER: &str = "x-jev-conformance-probe"; @@ -554,11 +591,155 @@ pub fn jev_subject_config(data: u16, admin: u16, key_file: &Path) -> String { advanced:\n allow_destinations: [\"127.0.0.1\"]\n\ groups:\n {ONE_REQUEST_GROUP}:\n limits:\n - {{ requests: 1, per: day }}\n\ decisions:\n models:\n {MODEL}:\n provider: {PROVIDER}\n\ - \x20 rate_card:\n {MODEL}: {{ units: {{ decision: 1 }} }}\n", + \x20 rate_card:\n {MODEL}: {{ units: {{ decision: 1, input_tokens: 0, output_tokens: 0 }} }}\n", + key_file.display() + ) +} + +/// The second boot's config: [`MODEL`] and [`UPSTREAM_MODEL`] (its `upstream_model` +/// [`UPSTREAM_NAME`]) on the one provider, each priced on its own card entry, the admin token, and +/// the far end's loopback address declared as an allowed destination. +pub fn jev_models_config(data: u16, admin: u16, key_file: &Path) -> String { + format!( + "listen: \"127.0.0.1:{data}\"\n\ + admin_listen: \"127.0.0.1:{admin}\"\n\ + store: {{ module: memory }}\n\ + providers:\n {PROVIDER}:\n api_key: {{ env: JEV_CONFORMANCE_PROVIDER_KEY }}\n\ + models: {{}}\n\ + identity-providers:\n admin-tokens: {{ module: admin-tokens, token: {{ env: BUSBAR_ADMIN_TOKEN }} }}\n\ + auth:\n chain: [keys]\n admin_auth: [admin-tokens]\n signing_key: {{ file: {} }}\n\ + advanced:\n allow_destinations: [\"127.0.0.1\"]\n\ + decisions:\n models:\n {MODEL}:\n provider: {PROVIDER}\n\ + \x20 {UPSTREAM_MODEL}:\n provider: {PROVIDER}\n upstream_model: {UPSTREAM_NAME}\n\ + \x20 rate_card:\n {MODEL}: {{ units: {{ decision: 1, input_tokens: 0, output_tokens: 0 }} }}\n\ + \x20 {UPSTREAM_MODEL}: {{ units: {{ decision: 1, input_tokens: 0, output_tokens: 0 }} }}\n", + key_file.display() + ) +} + +/// The rig's providers on the far end at `port`: the jev provider. +pub fn jev_providers(port: u16) -> String { + format!( + "{PROVIDER}:\n protocol: jev\n base_url: http://127.0.0.1:{port}\n error_map: {{}}\n" + ) +} + +/// The `nanojev` boot's providers on the far end at `port`: the jev provider and a `nanojev` one. +pub fn nanojev_providers(port: u16) -> String { + format!( + "{}{NANOJEV_PROVIDER}:\n protocol: nanojev\n base_url: http://127.0.0.1:{port}\n error_map: {{}}\n", + jev_providers(port) + ) +} + +/// The `nanojev` boot's config: [`MODEL`] on the jev provider and [`NANOJEV_MODEL`] on the +/// `nanojev` one, each priced on its own card entry. +pub fn jev_nanojev_config(data: u16, admin: u16, key_file: &Path) -> String { + format!( + "listen: \"127.0.0.1:{data}\"\n\ + admin_listen: \"127.0.0.1:{admin}\"\n\ + store: {{ module: memory }}\n\ + providers:\n {PROVIDER}:\n api_key: {{ env: JEV_CONFORMANCE_PROVIDER_KEY }}\n\ + \x20 {NANOJEV_PROVIDER}:\n api_key: {{ env: JEV_CONFORMANCE_PROVIDER_KEY }}\n\ + models: {{}}\n\ + identity-providers:\n admin-tokens: {{ module: admin-tokens, token: {{ env: BUSBAR_ADMIN_TOKEN }} }}\n\ + auth:\n chain: [keys]\n admin_auth: [admin-tokens]\n signing_key: {{ file: {} }}\n\ + advanced:\n allow_destinations: [\"127.0.0.1\"]\n\ + decisions:\n models:\n {MODEL}:\n provider: {PROVIDER}\n\ + \x20 {NANOJEV_MODEL}:\n provider: {NANOJEV_PROVIDER}\n\ + \x20 rate_card:\n {MODEL}: {{ units: {{ decision: 1, input_tokens: 0, output_tokens: 0 }} }}\n\ + \x20 {NANOJEV_MODEL}: {{ units: {{ decision: 1, input_tokens: 0, output_tokens: 0 }} }}\n", key_file.display() ) } +/// `nanojev.served`: the caller got the far end's 200 bytes over one dial, and the far end was asked +/// at `/api/evaluate` with the caller's bytes. +pub fn judge_nanojev_served(call: &Call, path: &str, received: &[u8]) -> Result<(), String> { + check( + call.status == 200 && call.body == NANOJEV_SUCCESS && call.hops == 1, + || { + format!( + "a NanoJev success answered {} `{}` over {} hop(s), not the far end's 200 bytes over one", + call.status, + show(&call.body), + call.hops + ) + }, + )?; + check(path == PATH_EVALUATE && received == NANOJEV_REQUEST, || { + format!( + "the far end was asked at `{path}` with `{}`, not at {PATH_EVALUATE} with the caller's bytes", + show(received) + ) + }) +} + +/// `nanojev.no-count-refused`: a success with no billing unit ends in a 5xx in jev's shape, never +/// the far end's bytes, over exactly one dial (no extra egress). +pub fn judge_nanojev_no_count(call: &Call) -> Result<(), String> { + if !(500..=599).contains(&call.status) || call.body == NANOJEV_NO_COUNT { + return Err(format!( + "a NanoJev success with no `execution.states` answered {} `{}`, not a 5xx refusal", + call.status, + show(&call.body) + )); + } + check(call.hops == 1, || { + format!("the unit made {} far-end hop(s), not one", call.hops) + })?; + jev_refusal_code(&call.body).map(|_| ()) +} + +/// A request naming `model` (none: [`REQUEST`]'s own bytes), the odd spacing kept. +pub fn models_request(model: Option<&str>) -> Vec { + match model { + None => REQUEST.to_vec(), + Some(m) => format!( + "{{ \"model\" : \"{m}\" , \"state\" : {{\"session\":\"conformance-state\"}},\n \"context\":{{}} }}" + ) + .into_bytes(), + } +} + +/// `models.unnamed-400` and `models.unknown-404`: the `status` refusal in jev's shape (`code`), and +/// no request at the far end. +pub fn judge_unrouted(call: &Call, status: u16, code: &str) -> Result<(), String> { + if call.status != status { + return Err(format!( + "answered {} `{}`, not the {status} refusal", + call.status, + show(&call.body) + )); + } + if call.hops != 0 { + return Err(format!( + "the unrouted request reached the far end ({} hop(s))", + call.hops + )); + } + is_jev_refusal(&call.body, code) +} + +/// `models.upstream-model`: the request naming [`UPSTREAM_MODEL`] is served (the far end's 200 bytes +/// over one dial), and the far end received `sent` with the top-level `model` value alone spliced +/// to [`UPSTREAM_NAME`]. +pub fn judge_upstream_model(call: &Call, sent: &[u8], received: &[u8]) -> Result<(), String> { + served_once(call)?; + let want = String::from_utf8_lossy(sent).replacen( + &format!("\"{UPSTREAM_MODEL}\""), + &format!("\"{UPSTREAM_NAME}\""), + 1, + ); + check(received == want.as_bytes(), || { + format!( + "the far end received `{}`, not the caller's bytes with `model` spliced to \ + `{UPSTREAM_NAME}`: `{want}`", + show(received) + ) + }) +} + fn check(ok: bool, finding: impl FnOnce() -> String) -> Result<(), String> { if ok { Ok(()) @@ -601,6 +782,10 @@ impl Runner { run.build = Ok(()); run.boot = Ok(()); self.served(&bin, &dir, &mut run); + if run.boot.is_ok() && !run.checks.is_empty() { + self.served_models(&bin, &dir, &mut run); + self.served_nanojev(&bin, &dir, &mut run); + } } Err(e) => run.build = Err(e), } @@ -641,10 +826,7 @@ impl Runner { } }; let config = jev_subject_config(data, admin, &key_file); - let providers = format!( - "{PROVIDER}:\n protocol: jev\n base_url: http://127.0.0.1:{}\n error_map: {{}}\n", - far.port - ); + let providers = jev_providers(far.port); let booted = match subject::boot( bin, dir, @@ -883,6 +1065,162 @@ impl Runner { } } +/// A booted subject of one of the extra legs: its far end, its data port and a minted caller. +struct Leg { + far: FarEnd, + scratch: std::path::PathBuf, + data: u16, + bearer: String, + _booted: subject::Booted, +} + +impl Leg { + /// One call on `path` with `body`, and the far-end hops it made. + fn call(&self, path: &str, body: &[u8]) -> Result { + let before = self.far.seen().len(); + let r = curl( + &self.scratch, + "POST", + &format!("http://127.0.0.1:{}{path}", self.data), + &[ + ("content-type", "application/json"), + ("authorization", self.bearer.as_str()), + ], + Some(body), + &[], + )?; + Ok(Call { + status: r.status, + body: r.body, + hops: self.far.seen().len() - before, + }) + } + + /// The body of the last request the far end received. + fn received(&self) -> Vec { + self.far + .seen() + .last() + .map(|s| s.body.clone()) + .unwrap_or_default() + } +} + +impl Runner { + /// Boot an extra leg's subject under `dir/`: its config written by `config` for its + /// ports and signing key, its providers by `providers` for the far end's port, and a caller + /// minted. + fn boot_leg( + &self, + bin: &Path, + dir: &Path, + name: &str, + config: fn(u16, u16, &Path) -> String, + providers: fn(u16) -> String, + ) -> Result { + let dir = dir.join(name); + let scratch = dir.join("http"); + let far = FarEnd::start()?; + let (ports, admin_token, provider_key) = match ( + subject::free_ports(2), + subject::random_hex(24), + subject::random_hex(16), + ) { + (Ok(p), Ok(a), Ok(k)) => (p, a, k), + (p, a, k) => { + return Err([p.err(), a.err(), k.err()] + .into_iter() + .flatten() + .collect::>() + .join("; ")) + } + }; + let (data, admin) = (ports[0], ports[1]); + let key_file = subject::signing_key(bin, &dir)?; + let booted = subject::boot( + bin, + &dir, + &config(data, admin, &key_file), + &providers(far.port), + &[ + ("BUSBAR_ADMIN_TOKEN", admin_token.clone()), + ("JEV_CONFORMANCE_PROVIDER_KEY", provider_key), + ], + &format!("http://127.0.0.1:{data}/stats"), + &[], + ) + .map_err(|e| format!("the {name} subject did not boot: {e}"))?; + let bearer = match subject::mint_key(&scratch, admin, &admin_token, &format!("jev-{name}")) + { + Ok((key, _)) => format!("Bearer {key}"), + Err(e) => return Err(format!("{e}: {}", booted.log_tail())), + }; + Ok(Leg { + far, + scratch, + data, + bearer, + _booted: booted, + }) + } + + /// THE MODEL-RESOLUTION BOOT: two models on the rig's far end, and the three `models.*` checks. + fn served_models(&self, bin: &Path, dir: &Path, run: &mut JevRun) { + let leg = match self.boot_leg(bin, dir, "models", jev_models_config, jev_providers) { + Ok(leg) => leg, + Err(why) => return run.checks.push(("models.boot".to_string(), Err(why))), + }; + run.checks.push(( + "models.unnamed-400".to_string(), + leg.call(PATH, &models_request(None)) + .and_then(|c| judge_unrouted(&c, 400, "invalid_request")), + )); + run.checks.push(( + "models.unknown-404".to_string(), + leg.call(PATH, &models_request(Some("jev-no-such-model"))) + .and_then(|c| judge_unrouted(&c, 404, "invalid_params")), + )); + leg.far.clear_replies(); + leg.far.reply(200, SUCCESS); + let sent = models_request(Some(UPSTREAM_MODEL)); + run.checks.push(( + "models.upstream-model".to_string(), + leg.call(PATH, &sent) + .and_then(|c| judge_upstream_model(&c, &sent, &leg.received())), + )); + } + + /// THE `nanojev` BOOT: a NanoJev model beside a jev one, and the three `nanojev.*` checks. + fn served_nanojev(&self, bin: &Path, dir: &Path, run: &mut JevRun) { + let leg = match self.boot_leg(bin, dir, "nanojev", jev_nanojev_config, nanojev_providers) { + Ok(leg) => leg, + Err(why) => return run.checks.push(("nanojev.boot".to_string(), Err(why))), + }; + leg.far.clear_replies(); + leg.far.reply(200, NANOJEV_SUCCESS); + run.checks.push(( + "nanojev.served".to_string(), + leg.call(PATH_EVALUATE, NANOJEV_REQUEST).and_then(|c| { + let seen = leg.far.seen(); + let path = seen.last().map(|s| s.path.clone()).unwrap_or_default(); + judge_nanojev_served(&c, &path, &leg.received()) + }), + )); + run.checks.push(( + "nanojev.wrong-dialect-404".to_string(), + leg.call(PATH_EVALUATE, &models_request(Some(MODEL))) + .and_then(|c| judge_unrouted(&c, 404, "invalid_params")), + )); + leg.far.clear_replies(); + leg.far.reply(200, NANOJEV_NO_COUNT); + run.checks.push(( + "nanojev.no-count-refused".to_string(), + leg.call(PATH_EVALUATE, NANOJEV_REQUEST) + .and_then(|c| judge_nanojev_no_count(&c)), + )); + } +} + /// How long a read waits for the node's postings and seals to land before it is final. const SETTLE: Duration = Duration::from_millis(300); /// How long a read waits for a posting or a seal the rig is owed. diff --git a/xtask/src/conformance_record/mod.rs b/xtask/src/conformance_record/mod.rs index 98811e748d..b3dc99427f 100644 --- a/xtask/src/conformance_record/mod.rs +++ b/xtask/src/conformance_record/mod.rs @@ -48,10 +48,14 @@ use crate::gitp; pub use a2a::{discriminates, governance_observed, NEGATIVE_PAIRS}; pub use h2::{decide_h2, junit_cases, Case, CaseResult, H2Run}; pub use jev::{ - decide_jev, is_jev_refusal, jev_refusal_code, jev_subject_config, judge_admit, judge_audit, - judge_audit_window, judge_exit, judge_jev_ledger, judge_meter, judge_refused_before_dial, - judge_route, ledger_spend, tests_passed, Call, JevRun, Spend, AUDIT_OP_CLASS, ELSEWHERE, - H2_CHECKS, ONE_REQUEST_GROUP, REPORTED_UNITS, SUCCESS as JEV_SUCCESS, UNAVAILABLE, + decide_jev, is_jev_refusal, jev_models_config, jev_nanojev_config, jev_providers, + jev_refusal_code, jev_subject_config, judge_admit, judge_audit, judge_audit_window, judge_exit, + judge_jev_ledger, judge_meter, judge_nanojev_no_count, judge_nanojev_served, + judge_refused_before_dial, judge_route, judge_unrouted, judge_upstream_model, ledger_spend, + models_request, nanojev_providers, tests_passed, Call, JevRun, Spend, AUDIT_OP_CLASS, + ELSEWHERE, H2_CHECKS, NANOJEV_MODEL, NANOJEV_NO_COUNT, NANOJEV_PROVIDER, NANOJEV_REQUEST, + NANOJEV_SUCCESS, ONE_REQUEST_GROUP, PATH_EVALUATE, REPORTED_UNITS, SUCCESS as JEV_SUCCESS, + UNAVAILABLE, UPSTREAM_MODEL, UPSTREAM_NAME, }; pub use oidf::{ as_signing_key, base64_std, decide_oidf, es256_jwk, idp_reply, module_results, diff --git a/xtask/src/gates/design_bindings/tables.rs b/xtask/src/gates/design_bindings/tables.rs index 3206dea949..1f5991582a 100644 --- a/xtask/src/gates/design_bindings/tables.rs +++ b/xtask/src/gates/design_bindings/tables.rs @@ -400,7 +400,7 @@ pub static SEED: &[(&str, &[SeededCheck])] = &[ ("test", "test_ssrf_allows_private_and_loopback_by_default", "no over-blocking, no runtime DNS check"), ("test", "test_validate_rejects_non_https_base_url", "the public-https / private-http scheme rule literals"), ("test", "test_validate_token_url_ssrf_and_scheme", "the same rules on token_url"), - ("test", "crates/busbar-kernel/src/tests/net_guard_tests.rs::the_shared_internal_predicate_covers_every_range_any_plane_ever_checked", "one shared predicate across planes"), + ("test", "crates/busbar-kernel-egress/src/trust/tests/net_tests.rs::the_shared_internal_predicate_covers_every_range_any_plane_ever_checked", "one shared predicate across planes"), ]), ("PB-73", &[ ("test", "server_timing_header_absent_by_default_present_when_enabled", "Server-Timing absent by default, on every response when enabled"), diff --git a/xtask/src/gates/instance_noun_neutrality.rs b/xtask/src/gates/instance_noun_neutrality.rs index 3ce65a4cab..1d3abb3e52 100644 --- a/xtask/src/gates/instance_noun_neutrality.rs +++ b/xtask/src/gates/instance_noun_neutrality.rs @@ -872,7 +872,7 @@ fn census_file(cx: &Ctx, f: &SourceFile) -> (Vec, Vec) { // file, and literals anywhere outside a dialect module (kernel, contract, root, loader, a // plane's non-dialect code). let blank_literals = in_dialect_module(cx, krate, &rel); - let mut in_block = false; + let mut in_block = crate::scan::LexState::default(); let mut lex = crate::scan::LexState::default(); let lines: Vec<(String, String)> = f .text diff --git a/xtask/src/gates/kernel_token_wire_purity.rs b/xtask/src/gates/kernel_token_wire_purity.rs index b1f358d985..d9dc960ab2 100644 --- a/xtask/src/gates/kernel_token_wire_purity.rs +++ b/xtask/src/gates/kernel_token_wire_purity.rs @@ -233,7 +233,7 @@ impl Gate for KernelTokenWirePurityGate { let mut offenders = Vec::new(); for f in &files { - let mut in_block = false; + let mut in_block = scan::LexState::default(); for (i, raw) in f.text.lines().enumerate() { // Comments stripped, string literals INTACT — the same rule the shell's // `strip_comments` awk applied. A wire field name inside a literal is still the @@ -293,6 +293,24 @@ impl Gate for KernelTokenWirePurityGate { &[&raw_field], )); + // A `/*` INSIDE A CONTINUED STRING IS STRING. The scanner reset its in-string flag on every + // line, so the `{root}/*"` continuation opened a block comment that never closed and every + // later line of the file read as comment: `crates/busbar-kernel/src/plugin_routes.rs` hid + // 292 of its 564 lines from this gate that way (audit xtask A2 HIGH 1, H1 HIGH 2). + report.push(plant( + cx, + self, + "a raw wire field after a `/*` inside a continued string is still read", + &[ROW_NO_RAW_FIELD], + &format!("{KERNEL_SRC}/planted_wire_read.rs"), + Edit::Create(format!( + "pub fn confine(root: &str) -> String {{\n format!(\"routes are confined to \ + \\\n {{root}}/*\")\n}}\npub fn class_of(v: &Value) -> i64 {{\n \ + v[\"{raw_field}\"].as_i64()\n}}\n" + )), + &["planted_wire_read.rs:6", &raw_field], + )); + // A COMMENT-ONLY MENTION IS PROSE. The rule that exempts it is the rule that lets the // kernel document the ban, and a gate its own explanation fails is a gate people delete. let mut ov = Overlay::new(); diff --git a/xtask/src/gates/no_deferral.rs b/xtask/src/gates/no_deferral.rs index 53cbc8a412..90e7a85c66 100644 --- a/xtask/src/gates/no_deferral.rs +++ b/xtask/src/gates/no_deferral.rs @@ -287,7 +287,7 @@ fn negated_test_pred(code: &str) -> bool { /// the shell's marker file does. `#[cfg(test)] mod { … }` bodies are excluded from BOTH classes. fn markers_in(rel: &str, text: &str) -> Vec { let mut out = Vec::new(); - let mut in_block = false; + let mut in_block = scan::LexState::default(); let mut testdepth: i32 = 0; let mut pend = false; let mut lex = scan::LexState::default(); @@ -913,6 +913,26 @@ impl Gate for NoDeferralGate { ], )); + // ── CLASS A AFTER A `/*` INSIDE A CONTINUED STRING. The shared scanner reset its in-string + // flag on every line, so the continuation's `/*` opened a block comment that never + // closed and the `todo!()` below it read as comment: the rest of + // `crates/busbar-kernel/src/plugin_routes.rs` was unread that way (audit xtask A2 HIGH 1, + // H1 HIGH 2). + let mut ov = Overlay::new(); + ov.set( + "crates/busbar-core/src/xtask_no_deferral_plant.rs", + "pub fn confine(root: &str) -> String {\n format!(\"routes are confined to \\\n \ + {root}/*\")\n}\npub fn arm() -> u8 { todo!() }\n", + ); + report.push(prove_red( + base, + self, + "a deferral after a `/*` inside a continued string is still a marker", + &[ROW_UNWAIVED], + ov, + &["xtask_no_deferral_plant.rs:5"], + )); + // ── CLASS B, on the raw line. let mut ov = Overlay::new(); ov.set( diff --git a/xtask/src/gates/no_self_filed_issues.rs b/xtask/src/gates/no_self_filed_issues.rs index 01cb82559b..85264976d2 100644 --- a/xtask/src/gates/no_self_filed_issues.rs +++ b/xtask/src/gates/no_self_filed_issues.rs @@ -42,9 +42,12 @@ pub const ROW_PERM: &str = "no-self-filed-issues:no-issues-permission"; const WF_DIR: &str = ".github/workflows"; -/// A discovery step that finds nothing passes everything. Twenty-odd workflows today; the floor is -/// the shell's, a `const` with no environment override. -const DISCOVERY_FLOOR: usize = 5; +/// A discovery step that finds nothing passes everything. The floor is the workflow count, a `const` +/// with no environment override: the four reusable `plugin-*.yml` workflows the plugin repos call. +/// It was the shell's 5 while `promote.yml` (the busbar-release hop) stood; that file is deleted +/// (OWNER 2026-10-12, "ModernCI only": busbar's moves are judged from `.ci/*.yaml`), so 5 would +/// read the honest tree as broken discovery. +const DISCOVERY_FLOOR: usize = 4; const CLEAN: &str = "the scan cleared its floor and named nothing"; diff --git a/xtask/src/gates/package_selectors.rs b/xtask/src/gates/package_selectors.rs index 0a92532e34..1e3973830b 100644 --- a/xtask/src/gates/package_selectors.rs +++ b/xtask/src/gates/package_selectors.rs @@ -1317,7 +1317,7 @@ mod tests { #[test] fn the_reader_is_picked_by_the_files_own_shape_and_only_markdown_is_unread() { assert_eq!(reader_for("xtask/src/gates/x.rs"), Reader::Rust); - assert_eq!(reader_for(".github/workflows/promote.yml"), Reader::Yaml); + assert_eq!(reader_for(".github/workflows/plugin-ci.yml"), Reader::Yaml); assert_eq!(reader_for(".github/actions/a/action.yaml"), Reader::Yaml); assert_eq!(reader_for("qa/pipeline-checks.json"), Reader::Json); assert_eq!(reader_for("qa/DESIGN-BINDINGS.md"), Reader::Prose); diff --git a/xtask/src/gates/seal_witness.rs b/xtask/src/gates/seal_witness.rs index 2fe639460d..f0298de671 100644 --- a/xtask/src/gates/seal_witness.rs +++ b/xtask/src/gates/seal_witness.rs @@ -626,7 +626,7 @@ fn scan_tree(cx: &Ctx) -> Result { // whose raw text holds the minter's name can mint: the lexers run on those alone, which is // what keeps a whole-tree scan per selftest plant cheap. if ZOO.iter().any(|z| f.text.contains(z)) { - let mut in_block = false; + let mut in_block = scan::LexState::default(); for (i, raw) in f.text.lines().enumerate() { let code = scan::strip_comment_line(raw, &mut in_block); for name in ZOO { @@ -643,7 +643,7 @@ fn scan_tree(cx: &Ctx) -> Result { // THE ONE TEST-SCOPE ANSWER every scanner in this crate uses: `gated` is true inside a // `#[cfg(test)]` item. let lines = scan::test_scope(&f.text); - let mut in_block = false; + let mut in_block = scan::LexState::default(); let code: Vec = f .text .lines() diff --git a/xtask/src/gates/ship_ready.rs b/xtask/src/gates/ship_ready.rs index b4815f6579..6abb1a9aac 100644 --- a/xtask/src/gates/ship_ready.rs +++ b/xtask/src/gates/ship_ready.rs @@ -60,8 +60,8 @@ pub struct ShipReadyGate; /// `HEAD` and the empty string are folded in here for the same reason: `git rev-parse --abbrev-ref /// HEAD` answers `HEAD` on a detached checkout, which is git saying it cannot name a branch, not /// git naming a branch called HEAD. Reading it as a dev-line name is the same fail-open one step -/// removed. The pipeline sets `XTASK_SHIP_TARGET` explicitly (`promote.yml`, the PR's base branch), which -/// is the supported way to say what a detached checkout is a checkout OF. +/// removed. The pipeline sets `XTASK_SHIP_TARGET` explicitly (`.ci/*.yaml`, the move's target branch), +/// which is the supported way to say what a detached checkout is a checkout OF. #[derive(Debug, Clone, PartialEq, Eq)] pub enum Posture { /// The target, named: `XTASK_SHIP_TARGET` when set and non-empty, else the branch git reports. diff --git a/xtask/src/gates/tests/workflow_rules_tests.rs b/xtask/src/gates/tests/workflow_rules_tests.rs index 7a4c011c5c..279daaa56d 100644 --- a/xtask/src/gates/tests/workflow_rules_tests.rs +++ b/xtask/src/gates/tests/workflow_rules_tests.rs @@ -175,210 +175,6 @@ fn every_rule_is_proven_able_to_go_red() { crate::gates::verify_report(&gate, &report).expect("every rule proven RED"); } -/// A two-job workflow: `slsa-build` (under `BUILDER_IF`) uploads the release artifact, and a shard -/// downloads it from its own run under `STEP_IF`, in the block spelling promote.yml uses. -const R18_FIXTURE: &str = "on: [pull_request, merge_group] -jobs: - slsa-build: - if: BUILDER_IF - steps: - - run: cargo build --release - - { uses: actions/upload-artifact@abc, with: { name: \"slsa-artifact-x\", path: slsa/ } } # v7 - hop-shard: - steps: - - if: STEP_IF - uses: actions/download-artifact@abc # v8 - with: { name: \"slsa-artifact-x\", path: slsa } - - run: ci hop -"; - -fn r18(builder_if: &str, step_if: &str) -> Vec { - let text = R18_FIXTURE - .replace("BUILDER_IF", builder_if) - .replace("STEP_IF", step_if); - release_artifact_findings("promote.yml", &text) - .into_iter() - .map(|f| { - assert_eq!(f.rule, "R18"); - f.message - }) - .collect() -} - -const NEEDS_ARTIFACT: &str = "needs.preflight.outputs.needs_release_artifact == 'true'"; - -#[test] -fn r18_a_merge_group_only_build_under_a_pull_request_download_is_red() { - // #740 exactly: the builder runs only in a merge group, the download whenever the rung reads it. - let got = r18( - "github.event_name == 'merge_group' && needs.preflight.outputs.run == '1'", - NEEDS_ARTIFACT, - ); - assert_eq!(got.len(), 1, "{got:?}"); - assert!( - got[0].contains("`hop-shard`") && got[0].contains("`slsa-build`"), - "{got:?}" - ); - // The same narrowing written as a base-branch test is the same red. - assert_eq!(r18("github.base_ref == 'qa'", NEEDS_ARTIFACT).len(), 1); -} - -#[test] -fn r18_a_builder_that_admits_the_download_condition_is_green() { - let fixed = format!( - "needs.preflight.outputs.run == '1' && (github.event_name == 'merge_group' || {NEEDS_ARTIFACT})" - ); - assert!(r18(&fixed, NEEDS_ARTIFACT).is_empty()); - // Wrapped in `${{ }}` the same. - assert!(r18(&format!("${{{{ {fixed} }}}}"), NEEDS_ARTIFACT).is_empty()); - // A builder narrowed only by the run gate, not by event, runs wherever the run runs. - assert!(r18("needs.preflight.outputs.run == '1'", NEEDS_ARTIFACT).is_empty()); -} - -#[test] -fn r18_a_download_that_waits_on_the_builders_success_is_green() { - // The soak's spelling: it downloads only after slsa-build succeeded, else builds its own. - assert!(r18( - "github.event_name == 'merge_group'", - "needs.slsa-build.result == 'success'" - ) - .is_empty()); -} - -#[test] -fn r18_an_unconditional_download_under_an_event_narrowed_builder_is_red() { - let text = R18_FIXTURE - .replace("BUILDER_IF", "github.event_name == 'merge_group'") - .replace(" - if: STEP_IF\n uses:", " - uses:"); - assert_eq!(release_artifact_findings("promote.yml", &text).len(), 1); -} - -#[test] -fn r18_a_download_with_no_builder_in_the_run_is_red_and_a_run_id_download_is_not_judged() { - let orphan = R18_FIXTURE - .replace("BUILDER_IF", "github.event_name == 'merge_group'") - .replace("actions/upload-artifact@abc", "actions/cache@abc") - .replace("STEP_IF", NEEDS_ARTIFACT); - let got = release_artifact_findings("promote.yml", &orphan); - assert_eq!(got.len(), 1); - assert!(got[0] - .message - .contains("no job of this workflow uploads it")); - // Another run's artifact (by run-id) is not this run's to build. - let other = orphan.replace( - "with: { name: \"slsa-artifact-x\", path: slsa }", - "with: { name: \"slsa-artifact-x\", path: slsa, run-id: \"${{ env.RUN }}\" }", - ); - assert!(release_artifact_findings("promote.yml", &other).is_empty()); -} - -#[test] -fn r18_a_flow_step_condition_is_read_up_to_the_next_key() { - assert_eq!( - step_condition(&[" - { if: always(), uses: actions/upload-artifact@abc }"]).as_deref(), - Some("always()") - ); - assert_eq!( - step_condition(&[" - { uses: a@b, if: \"${{ x == 'y' }}\", with: { n: 1 } }"]) - .as_deref(), - Some("x == 'y'") - ); - assert_eq!(step_condition(&[" - run: true"]), None); -} - -#[test] -fn r18_the_mutation_anchor_rewrites_only_the_named_jobs_if() { - let t = "jobs:\n a:\n if: one\n slsa-build:\n if: two\n needs: a\n"; - assert_eq!( - reif_job(t, "slsa-build", "three"), - "jobs:\n a:\n if: one\n slsa-build:\n if: three\n needs: a\n" - ); - assert_eq!(reif_job(t, "absent", "three"), t); -} - -/// R19: a shard that runs the hop on the candidate with the read-only keys, and the publisher that -/// runs no candidate code with the write keys. -const R19_FIXTURE: &str = "on: [pull_request, merge_group] -jobs: - hop-shard: - steps: - - { uses: actions/checkout@abc, with: { ref: x, path: busbar } } # v7 - - { uses: actions/checkout@abc, with: { repository: GetBusbar/busbar-release, ref: x, path: busbar-release } } # v7 - - run: ci hop - env: { R2_RO_KEY_ID: \"${{ secrets.R2_CACHE_RO_ACCESS_KEY_ID }}\", R2_RO_KEY_SECRET: \"${{ secrets.R2_CACHE_RO_SECRET_ACCESS_KEY }}\" } - cache-publish: - needs: [hop-shard] - steps: - - run: echo the engine - - run: ci cache-publish - env: { R2_KEY_ID: \"${{ secrets.R2_CACHE_ACCESS_KEY_ID }}\", R2_KEY_SECRET: \"${{ secrets.R2_CACHE_SECRET_ACCESS_KEY }}\" } -"; - -fn r19(text: &str) -> Vec { - r2_write_key_findings("promote.yml", text) - .into_iter() - .map(|f| { - assert_eq!(f.rule, "R19"); - f.message - }) - .collect() -} - -#[test] -fn r19_read_only_keys_on_the_candidate_and_write_keys_on_the_publisher_are_green() { - let got = r19(R19_FIXTURE); - assert!(got.is_empty(), "{got:?}"); -} - -#[test] -fn r19_a_write_key_on_a_job_that_runs_candidate_code_is_red() { - // The shard as it stood before busbar-release 069f032. - let shard = R19_FIXTURE.replacen( - "R2_RO_KEY_ID: \"${{ secrets.R2_CACHE_RO_ACCESS_KEY_ID }}\"", - "R2_KEY_ID: \"${{ secrets.R2_CACHE_ACCESS_KEY_ID }}\"", - 1, - ); - let got = r19(&shard); - assert_eq!(got.len(), 1, "{got:?}"); - assert!( - got[0].contains("`hop-shard`") && got[0].contains("secrets.R2_CACHE_ACCESS_KEY_ID"), - "{got:?}" - ); - // A job that only builds (cargo, no checkout step of its own) runs candidate code too. - let build = "jobs:\n b:\n steps:\n - run: cargo build\n env: { K: \"${{ secrets.R2_CACHE_SECRET_ACCESS_KEY }}\" }\n"; - assert_eq!(r19(build).len(), 1); -} - -#[test] -fn r19_a_workflow_level_write_key_reaches_every_candidate_job() { - let text = String::from("env:\n K: \"${{ secrets.R2_CACHE_ACCESS_KEY_ID }}\"\n") + R19_FIXTURE; - let got = r19(&text); - assert_eq!(got.len(), 1, "{got:?}"); - assert!(got[0].contains("`hop-shard`"), "{got:?}"); -} - -#[test] -fn r19_reads_a_secret_whole_and_another_repositorys_checkout_as_no_candidate() { - assert!(r2_write_secrets_in("${{ secrets.R2_CACHE_ACCESS_KEY_ID2 }}").is_empty()); - assert!(r2_write_secrets_in("${{ secrets.R2_CACHE_RO_ACCESS_KEY_ID }}").is_empty()); - assert!(r2_write_secrets_in("${{ secrets.R2_CACHE_RO_SECRET_ACCESS_KEY }}").is_empty()); - assert_eq!( - r2_write_secrets_in("${{ secrets.R2_CACHE_SECRET_ACCESS_KEY }}"), - vec!["secrets.R2_CACHE_SECRET_ACCESS_KEY"] - ); - let other = "jobs:\n p:\n steps:\n - { uses: actions/checkout@abc, with: { repository: GetBusbar/busbar-release, ref: x } } # v7\n - run: ci cache-publish\n env: { K: \"${{ secrets.R2_CACHE_ACCESS_KEY_ID }}\" }\n"; - assert!(r19(other).is_empty()); -} - -const ENGINE_VAR_JOB: &str = "env:\n SHA: x\njobs:\n pre:\n env: { RELEASE_REF: \"${{ vars.ENGINE_REF }}\", ENGINE_SHA256: \"${{ vars.ENGINE_SHA256 }}\" }\n steps:\n - name: Engine\n run: |\n [[ \"$RELEASE_REF\" =~ ^[0-9a-f]{40}$ ]] || { echo bad; exit 1; }\n name=\"busbar-release-ci-linux-x86_64-$RELEASE_REF\"\n echo \"$ENGINE_SHA256 bin\" | sha256sum -c -\n - { uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1, with: { repository: GetBusbar/busbar-release, ref: \"${{ env.RELEASE_REF }}\", path: busbar-release } } # v7\n"; - -fn r16(text: &str) -> Vec { - engine_pin_findings("w.yml", text) - .into_iter() - .map(|f| f.message) - .collect() -} - fn r17(text: &str) -> Vec { vendor_runs_on(text) .into_iter() @@ -386,78 +182,6 @@ fn r17(text: &str) -> Vec { .collect() } -#[test] -fn r16_accepts_the_variable_pin_with_its_shape_check_and_its_sha256() { - assert_eq!(r16(ENGINE_VAR_JOB), Vec::::new()); -} - -#[test] -fn r16_accepts_an_in_file_sha_pin() { - let t = "jobs:\n a:\n steps:\n - { uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1, with: { repository: GetBusbar/busbar-release, ref: 7ab21912d664d11c7df6cc98fe78eed6b0119177 } } # v7\n"; - assert_eq!(r16(t), Vec::::new()); - // and through a workflow-level env var holding the literal - let t = "env:\n RELEASE_REF: 7ab21912d664d11c7df6cc98fe78eed6b0119177\njobs:\n a:\n steps:\n - { uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1, with: { repository: GetBusbar/busbar-release, ref: \"${{ env.RELEASE_REF }}\" } } # v7\n"; - assert_eq!(r16(t), Vec::::new()); -} - -#[test] -fn r16_refuses_a_branch_or_a_tag_ref() { - // RED: the checkout at a branch. - let t = ENGINE_VAR_JOB.replace("ref: \"${{ env.RELEASE_REF }}\"", "ref: dev"); - assert!(r16(&t).iter().any(|m| m.contains("`dev`")), "{:?}", r16(&t)); - // RED: the variable is defined as a tag. - let t = ENGINE_VAR_JOB.replace( - "RELEASE_REF: \"${{ vars.ENGINE_REF }}\"", - "RELEASE_REF: v1.6.0", - ); - assert!( - r16(&t).iter().any(|m| m.contains("v1.6.0")), - "{:?}", - r16(&t) - ); - // RED: in-file, but a tag at the workflow level. - let t = "env:\n RELEASE_REF: refs/tags/v1\njobs:\n a:\n steps:\n - { uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1, with: { repository: GetBusbar/busbar-release, ref: \"${{ env.RELEASE_REF }}\" } } # v7\n"; - assert!(!r16(t).is_empty()); - // RED: no ref at all (the default branch). - let t = "jobs:\n a:\n steps:\n - { uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1, with: { repository: GetBusbar/busbar-release } } # v7\n"; - assert!(r16(t).iter().any(|m| m.contains("no `ref:`"))); -} - -#[test] -fn r16_refuses_a_variable_pin_without_its_shape_check_or_its_sha256() { - let t = ENGINE_VAR_JOB.replace("=~ ^[0-9a-f]{40}$", "!= \"\""); - assert!( - r16(&t).iter().any(|m| m.contains("default branch")), - "{:?}", - r16(&t) - ); - let t = ENGINE_VAR_JOB.replace("sha256sum -c -", "cat"); - let got = r16(&t); - assert_eq!( - got.len(), - 2, - "the download and the pin both name the missing check: {got:?}" - ); - // The shape check AFTER the checkout guards nothing. - let t = "jobs:\n pre:\n env: { RELEASE_REF: \"${{ vars.ENGINE_REF }}\" }\n steps:\n - { uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1, with: { repository: GetBusbar/busbar-release, ref: \"${{ env.RELEASE_REF }}\" } } # v7\n - run: |\n [[ \"$RELEASE_REF\" =~ ^[0-9a-f]{40}$ ]] || exit 1\n echo \"$ENGINE_SHA256 b\" | sha256sum -c -\n"; - assert!(r16(t).iter().any(|m| m.contains("default branch"))); -} - -#[test] -fn a_key_value_is_read_whole_from_a_flow_mapping() { - assert_eq!( - key_value("{ repository: o/r, ref: \"${{ env.X }}\", path: p }", "ref").as_deref(), - Some("${{ env.X }}") - ); - assert_eq!( - key_value("{ ref: dev, path: p }", "ref").as_deref(), - Some("dev") - ); - assert_eq!(key_value("{ prefref: dev }", "ref"), None); - assert_eq!(env_ref("${{ env.RELEASE_REF }}"), Some("RELEASE_REF")); - assert_eq!(env_ref("${{ vars.ENGINE_REF }}"), None); -} - #[test] fn a_vendor_runs_on_label_is_refused_in_every_spelling() { assert_eq!( diff --git a/xtask/src/gates/workflow_rules.rs b/xtask/src/gates/workflow_rules.rs index 261a98fd9e..30b80982fd 100644 --- a/xtask/src/gates/workflow_rules.rs +++ b/xtask/src/gates/workflow_rules.rs @@ -2,14 +2,16 @@ //! //! This gate was `release-order`: "nothing may be tagged until it has been verified from the //! consumer side", enforced over the SHAPE OF busbar's own release workflows (`release.yml`, -//! `release-stage.yml`, `docker.yml`, `verify-deploy.yml`). Those workflows are deleted: busbar has -//! one pipeline workflow, `promote.yml`, and the release engine lives in busbar-release. The rules -//! that were ABOUT those files (the staged-verification graph, the draft flag, the red-branch gate, -//! the first gate's run list) went with them, rows and code together. +//! `release-stage.yml`, `docker.yml`, `verify-deploy.yml`). Those workflows are deleted, and so is +//! `promote.yml`, the busbar-release hop (OWNER 2026-10-12, "ModernCI only"): busbar's moves are +//! judged by ModernCI from `.ci/*.yaml`, which this gate does not read. The rules that were ABOUT +//! those files (the staged-verification graph, the draft flag, the red-branch gate, the first gate's +//! run list, and promote.yml's engine pin, release-artifact wiring and R2 keys, R16/R18/R19) went +//! with them, rows and code together. //! //! Five rules were never about one file. They are bans over EVERY workflow and composite action -//! that remains (`promote.yml` and the reusable `plugin-*.yml` workflows the plugin repos call), -//! and each has a specific observed failure behind it: +//! that remains (the reusable `plugin-*.yml` workflows the plugin repos call), and each has a +//! specific observed failure behind it: //! //! * **R1 — no workflow is triggered by a version tag.** A tag is the OUTPUT of a verified release, //! never its trigger. @@ -18,22 +20,8 @@ //! * **R14 — every third-party action runs from a commit sha** with its tag as a trailing comment, //! because an action that can be force-moved under a name we already trust runs with our tokens. //! * **R15 — every attestation verify names the workflow that signed**, not just the repository. -//! * **R16 — the engine runs from a commit, never a branch or a tag.** Every checkout of -//! busbar-release names a full 40-hex sha, either in the file or through an env var the job -//! shape-checks as 40 hex before the checkout (the org variable `vars.ENGINE_REF`, OWNER -//! 2026-10-10: the engine pin lives outside this repo), and every job that downloads the engine -//! binary runs it only after `sha256sum -c` against `ENGINE_SHA256`. //! * **R17 — no job hard-codes a vendor runner label.** Every job asks the runner gateway for a //! logical size and `vars.RUNNERS` names the label, so a workflow never spells a vendor. -//! * **R18 — a step that downloads the release artifact from its own run runs only where the job -//! that builds it runs.** `slsa-build` was merge-group-only while the predev->dev pull request's -//! shards (its rung runs the oracle and conformance) downloaded `slsa-artifact-` from their -//! own run: the artifact could never exist there, and #740 failed every run at that download. -//! * **R19 — no job that runs candidate code holds the R2 cache write keys.** The hop judges the -//! candidate's build scripts, tests and xtask in-process, and every process of a job can read -//! another's environment: the shards held `secrets.R2_CACHE_ACCESS_KEY_ID` / -//! `secrets.R2_CACHE_SECRET_ACCESS_KEY` until busbar-release 069f032, which refuses to start a -//! hop that holds them. They belong to `cache-publish` alone, a job that runs no candidate code. //! //! **The parser stays deliberately small, and here that is a correctness argument rather than a //! dependency one.** The rules are assertions about what a human WROTE (a trailing `# tag` comment @@ -61,28 +49,12 @@ const WORKFLOWS: &str = ".github/workflows"; const ACTIONS_DIR: &str = ".github/actions"; /// The floor under the workflow discovery. A scan that finds nothing satisfies every ban, and a -/// tree with fewer workflows than this has lost its subject, not been cleaned: `promote.yml` plus -/// the four reusable `plugin-*.yml` workflows. -const MIN_WORKFLOWS: usize = 5; +/// tree with fewer workflows than this has lost its subject, not been cleaned: the four reusable +/// `plugin-*.yml` workflows. +const MIN_WORKFLOWS: usize = 4; /// The rule ids, which are also the ledger row ids. -const RULES: &[&str] = &["R1", "R11", "R14", "R15", "R16", "R17", "R18", "R19"]; - -/// The release artifact's name prefix: `promote.yml`'s `slsa-build` uploads `slsa-artifact-`, -/// and the steps that read the release binary download it by that name (R18). -const RELEASE_ARTIFACT: &str = "slsa-artifact-"; -/// R16's subject: the release engine's repository, and the name prefix of its CI binary on the -/// `dev-bin` release. -const ENGINE_REPO: &str = "GetBusbar/busbar-release"; -const ENGINE_ASSET: &str = "busbar-release-ci-"; -/// The shape test R16 requires of a variable engine pin, and the bytes check of the binary. -const SHA40_TEST: &str = "=~ ^[0-9a-f]{40}$"; -const SHA256_CHECK: &str = "sha256sum -c"; -const SHA256_VAR: &str = "ENGINE_SHA256"; -/// The pinned judge's binary on the `dev-bin` release, and the digest its job checks it against -/// (`sha256_linux_x86_64=` of testing/shadow-oracle/oracle-rust.pin). -const ORACLE_ASSET: &str = "busbar-oracle-linux-x86_64-"; -const ORACLE_SHA256_VAR: &str = "ORACLE_SHA256"; +const RULES: &[&str] = &["R1", "R11", "R14", "R15", "R17"]; // ------------------------------------------------------------------------------------------- // THE SMALL PARSER @@ -379,257 +351,6 @@ fn pushes_bare_release_branch(cmd: &str) -> bool { false } -/// A line that opens a YAML key at exactly `indent` spaces: not a comment, not a sequence item, -/// not deeper. -fn opens_key_at(line: &str, indent: usize) -> bool { - let b = line.as_bytes(); - b.len() > indent - && b[..indent].iter().all(|c| *c == b' ') - && !matches!(b[indent], b' ' | b'\t' | b'#' | b'-') -} - -/// An `if:` condition as written, without its `${{ }}` wrapper or the double quotes around the -/// whole of it (a single quote that ends the condition, `== 'true'`, is the condition's own). -fn bare_condition(raw: &str) -> String { - let c = raw.trim(); - let c = c - .strip_prefix('"') - .and_then(|r| r.strip_suffix('"')) - .unwrap_or(c) - .trim(); - let c = c.strip_prefix("${{").unwrap_or(c); - let c = c.strip_suffix("}}").unwrap_or(c); - c.trim().to_string() -} - -/// A job's own `if:` (the line opening `if:` at four spaces), bare. `None`: the job is -/// unconditional. -fn job_condition(lines: &[&str]) -> Option { - lines - .iter() - .find(|l| opens_key_at(l, 4) && l.trim_start().starts_with("if:")) - .map(|l| bare_condition(&l.trim_start()["if:".len()..])) -} - -/// A job's steps: each step's lines, its item line first. The items sit at the indent of the first -/// `- ` line under `steps:`; deeper lines (a `run: |` body, a block `with:`) belong to the step. -fn steps_of<'a>(lines: &[&'a str]) -> Vec> { - let mut out: Vec> = Vec::new(); - let Some(start) = lines - .iter() - .position(|l| opens_key_at(l, 4) && l.trim_end() == " steps:") - else { - return out; - }; - let mut indent: Option = None; - for line in &lines[start + 1..] { - if opens_key_at(line, 4) { - break; - } - let lead = line.len() - line.trim_start().len(); - let item = line.trim_start().starts_with("- "); - if item && indent.is_none() { - indent = Some(lead); - } - if item && Some(lead) == indent { - out.push(vec![*line]); - } else if let Some(step) = out.last_mut() { - step.push(*line); - } - } - out -} - -/// A step's own `if:`, bare, in either spelling: `- if: X` or an `if:` line of the step (block), -/// or `- { if: X, uses: ... }` (flow). `None`: the step is unconditional. -fn step_condition(step: &[&str]) -> Option { - let first = step.first()?.trim_start().strip_prefix("- ")?.trim_start(); - if let Some(flow) = first.strip_prefix('{') { - let flow = flow.trim_start(); - let rest = match flow.strip_prefix("if:") { - Some(r) => r, - None => &flow[flow.find(", if:")? + ", if:".len()..], - }; - let end = [ - ", uses:", - ", run:", - ", with:", - ", id:", - ", name:", - ", env:", - ", working-directory:", - ", continue-on-error:", - ] - .iter() - .filter_map(|k| rest.find(k)) - .min() - .unwrap_or(rest.len()); - return Some(bare_condition(&rest[..end])); - } - if let Some(rest) = first.strip_prefix("if:") { - return Some(bare_condition(rest)); - } - step[1..] - .iter() - .map(|l| l.trim_start()) - .find_map(|l| l.strip_prefix("if:")) - .map(bare_condition) -} - -/// R18 over one workflow's comment-stripped text. Every step that downloads the release artifact -/// from its OWN run (no `run-id:`) must run only where a job of this run uploads it. The step is -/// safe when it waits on the builder's success (`needs..result == 'success'`), or when -/// the builder's `if:` does not narrow it by event; a builder narrowed by event -/// (`github.event_name`, `github.base_ref`) must also admit the step's own condition, written into -/// its `if:` as one of the alternatives. No builder at all is the same red: the artifact can never -/// exist in that run. -fn release_artifact_findings(name: &str, text: &str) -> Vec { - fn carries(step: &[&str], verb: &str) -> bool { - let body = step.join("\n"); - body.contains(verb) && body.contains(RELEASE_ARTIFACT) - } - let jobs = jobs_of(text); - let builders: Vec<(&str, Option)> = jobs - .iter() - .filter(|(_, lines)| { - steps_of(lines) - .iter() - .any(|s| carries(s, "upload-artifact")) - }) - .map(|(id, lines)| (id.as_str(), job_condition(lines))) - .collect(); - let mut bad = Vec::new(); - for (job, lines) in &jobs { - for step in steps_of(lines) { - if !carries(&step, "download-artifact") || step.join("\n").contains("run-id:") { - continue; - } - let gate = step_condition(&step).unwrap_or_default(); - let when = if gate.is_empty() { - "always" - } else { - gate.as_str() - }; - if builders.is_empty() { - bad.push(Finding::new( - "R18", - format!( - "{name}: job `{job}` downloads the release artifact \ - (`{RELEASE_ARTIFACT}*`) from its own run, and no job of this workflow \ - uploads it, so the download can never find it. Build it in this run, or \ - download it by `run-id:` from the run that built it." - ), - )); - continue; - } - for (builder, cond) in &builders { - if gate.contains(&format!("needs.{builder}.result == 'success'")) { - continue; - } - let Some(cond) = cond else { continue }; - let by_event = - cond.contains("github.event_name") || cond.contains("github.base_ref"); - if by_event && (gate.is_empty() || !cond.contains(gate.as_str())) { - bad.push(Finding::new( - "R18", - format!( - "{name}: job `{job}` downloads the release artifact \ - (`{RELEASE_ARTIFACT}*`) from its own run when `{when}`, but \ - `{builder}`, the job that uploads it, runs only when `{cond}`. In \ - every run where the first holds and the second does not, the artifact \ - does not exist and the download fails (#740: a merge-group-only \ - slsa-build under the predev->dev pull request's shards). Admit \ - `{when}` in `{builder}`'s `if:`." - ), - )); - } - } - } - } - bad -} - -/// R19's subject: the R2 cache's WRITE keys, as this repository's secrets name them. The read-only -/// pair (`secrets.R2_CACHE_RO_*`) is another name, not a longer spelling of these. -const R2_WRITE_SECRETS: [&str; 2] = [ - "secrets.R2_CACHE_ACCESS_KEY_ID", - "secrets.R2_CACHE_SECRET_ACCESS_KEY", -]; - -/// The R2 write-key secrets `text` names, each as a whole name (`secrets.R2_CACHE_ACCESS_KEY_ID2` -/// is not `secrets.R2_CACHE_ACCESS_KEY_ID`). -fn r2_write_secrets_in(text: &str) -> Vec<&'static str> { - R2_WRITE_SECRETS - .into_iter() - .filter(|s| { - text.match_indices(*s).any(|(at, m)| { - !text[at + m.len()..] - .chars() - .next() - .is_some_and(|c| c.is_ascii_alphanumeric() || c == '_') - }) - }) - .collect() -} - -/// How job `lines` runs candidate code, when it does: a step checks out this repository (an -/// `actions/checkout` with no `repository:` is the candidate), runs cargo, or runs the hop (`ci -/// hop` judges the candidate in-process). A job that only downloads the engine, checks out another -/// repository or downloads artifacts runs none. -fn candidate_code(lines: &[&str]) -> Option<&'static str> { - for step in steps_of(lines) { - let body = step.join("\n"); - if body.contains("actions/checkout@") && key_value(&body, "repository").is_none() { - return Some("checks out the candidate"); - } - if body.contains("cargo ") { - return Some("runs cargo"); - } - if body.contains("ci hop") { - return Some("runs `ci hop` on the candidate"); - } - } - None -} - -/// R19 over one workflow's comment-stripped text. A job that runs candidate code names no R2 cache -/// write key: not in its own lines (job env, step env, `with:`), and not through the workflow's -/// top-level `env:`, which every job inherits. -fn r2_write_key_findings(name: &str, text: &str) -> Vec { - let top = r2_write_secrets_in(&top_level_block(text, "env")); - let mut bad = Vec::new(); - for (job, lines) in jobs_of(text) { - let Some(how) = candidate_code(&lines) else { - continue; - }; - let mut held = r2_write_secrets_in(&lines.join("\n")); - for s in &top { - if !held.contains(s) { - held.push(*s); - } - } - if held.is_empty() { - continue; - } - let named = held - .iter() - .map(|s| format!("`{s}`")) - .collect::>() - .join(", "); - bad.push(Finding::new( - "R19", - format!( - "{name}: job `{job}` {how} and names the R2 cache write key(s) {named}: every \ - candidate process the job starts can read them from its environment, and `ci hop` \ - refuses to start with them (INFRA). Give it the read-only `secrets.R2_CACHE_RO_*` \ - keys; the write keys belong to `cache-publish` alone, a job that runs no candidate \ - code." - ), - )); - } - bad -} - /// True when there is an actual composite-action manifest to scan, either on disk or planted by a /// selftest overlay. Mirrors `Ctx::list`'s own `overlay_adds_it` test: `.github/actions` need not /// exist in the real tree today (it does not) for a plant UNDER it to be a legitimate root, exactly @@ -681,215 +402,6 @@ fn action_manifests(cx: &Ctx) -> Result, String> { Ok(out) } -/// The jobs of a workflow (comments already stripped): each job's name and its lines, from its -/// header (a key at exactly two spaces under the top-level `jobs:`) to the next job or top-level key. -fn jobs_of(text: &str) -> Vec<(String, Vec<&str>)> { - let mut out: Vec<(String, Vec<&str>)> = Vec::new(); - let mut in_jobs = false; - for line in text.lines() { - if !line.starts_with(' ') && !line.trim().is_empty() { - in_jobs = line.trim_end() == "jobs:"; - continue; - } - if !in_jobs { - continue; - } - let b = line.as_bytes(); - if b.len() > 2 && b[0] == b' ' && b[1] == b' ' && b[2] != b' ' { - if let Some(name) = line.trim().strip_suffix(':') { - out.push((name.to_string(), Vec::new())); - continue; - } - } - if let Some((_, lines)) = out.last_mut() { - lines.push(line); - } - } - out -} - -/// The value of `key:` in `text` (a flow mapping or a block line), the first time `key` appears as -/// a key (not as the tail of a longer one): a quoted string up to its closing quote, an `${{ }}` -/// expression whole, or a bare scalar up to whitespace, `,` or `}`. -fn key_value(text: &str, key: &str) -> Option { - let needle = format!("{key}:"); - let mut from = 0usize; - while let Some(rel) = text[from..].find(&needle) { - let at = from + rel; - from = at + needle.len(); - let before = text[..at].chars().next_back(); - if before.is_some_and(|c| c.is_alphanumeric() || c == '_' || c == '-' || c == '.') { - continue; - } - let rest = text[from..].trim_start_matches([' ', '\t']); - let v = if let Some(q) = rest.strip_prefix('"') { - q.split('"').next().unwrap_or("") - } else if let Some(q) = rest.strip_prefix('\'') { - q.split('\'').next().unwrap_or("") - } else if rest.starts_with("${{") { - rest.find("}}").map(|i| &rest[..i + 2]).unwrap_or(rest) - } else { - let end = rest - .find(|c: char| c.is_whitespace() || c == ',' || c == '}') - .unwrap_or(rest.len()); - &rest[..end] - }; - return Some(v.trim().to_string()); - } - None -} - -/// `${{ env.NAME }}` -> `NAME`. -fn env_ref(v: &str) -> Option<&str> { - let inner = v.strip_prefix("${{")?.strip_suffix("}}")?.trim(); - let name = inner.strip_prefix("env.")?; - (!name.is_empty() && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')) - .then_some(name) -} - -/// The step span holding line `at` of `lines`: from the nearest `- ` item at or above it to the -/// next item at that indent (exclusive). -fn step_span(lines: &[&str], at: usize) -> String { - let indent = |l: &str| l.len() - l.trim_start().len(); - let start = (0..=at) - .rev() - .find(|&i| lines[i].trim_start().starts_with("- ")) - .unwrap_or(at); - let ind = indent(lines[start]); - let end = (start + 1..lines.len()) - .find(|&i| { - let l = lines[i]; - !l.trim().is_empty() && indent(l) <= ind - }) - .unwrap_or(lines.len()); - lines[start..end].join("\n") -} - -/// R16 over one workflow's text (comments stripped). -/// -/// THE ENGINE IS PINNED BY A COMMIT, AND ITS BYTES BY A DIGEST. promote.yml runs busbar-release's -/// engine with the jobs' tokens; a checkout at a branch or a tag runs whatever that name points at -/// today. The pin may be written in the file (a 40-hex sha) or held outside it (OWNER 2026-10-10: -/// the org variable `vars.ENGINE_REF`, so an engine update never waits in predev's merge queue). The -/// variable form is accepted only when the job proves at run time what the file can no longer show: -/// a step BEFORE the checkout tests the variable against 40 hex and exits on anything else (an -/// unset variable must FAIL, never fall back to the default branch an empty `ref:` checks out), and -/// the downloaded binary runs only after `sha256sum -c` against `ENGINE_SHA256`. -fn engine_pin_findings(name: &str, text: &str) -> Vec { - let mut bad = Vec::new(); - let top_env = top_level_block(text, "env"); - for (job, lines) in jobs_of(text) { - let job_text = lines.join("\n"); - let downloads = job_text.contains(ENGINE_ASSET); - let verifies = lines - .iter() - .any(|l| l.contains(SHA256_CHECK) && l.contains(SHA256_VAR)); - // THE JUDGE'S BYTES TOO: a job that downloads the pinned oracle runs it only after - // `sha256sum -c` against the pin's digest. - if job_text.contains(ORACLE_ASSET) - && !lines - .iter() - .any(|l| l.contains(SHA256_CHECK) && l.contains(ORACLE_SHA256_VAR)) - { - bad.push(Finding::new( - "R16", - format!( - "{name}: job `{job}` downloads the pinned oracle ({ORACLE_ASSET}*) without \ - `{SHA256_CHECK}` against ${ORACLE_SHA256_VAR} (oracle-rust.pin's \ - sha256_linux_x86_64): the judge would be whatever the release holds." - ), - )); - } - if downloads && !verifies { - bad.push(Finding::new( - "R16", - format!( - "{name}: job `{job}` downloads the engine binary ({ENGINE_ASSET}*) without \ - `{SHA256_CHECK}` against ${SHA256_VAR}: the bytes it runs with this job's \ - tokens are whatever the release holds under that name today." - ), - )); - } - for (i, line) in lines.iter().enumerate() { - if !line.contains(&format!("repository: {ENGINE_REPO}")) { - continue; - } - let span = step_span(&lines, i); - let Some(r) = key_value(&span, "ref").filter(|r| !r.is_empty()) else { - bad.push(Finding::new( - "R16", - format!( - "{name}: job `{job}` checks out {ENGINE_REPO} with no `ref:`, so it runs \ - the engine's default branch, not a pinned commit." - ), - )); - continue; - }; - if is_sha40(&r) { - continue; - } - let Some(var) = env_ref(&r) else { - bad.push(Finding::new( - "R16", - format!( - "{name}: job `{job}` checks out {ENGINE_REPO} at `{r}`, a branch or tag \ - the engine's owner can move, not a commit. Pin a 40-hex sha, or \ - `${{{{ env.RELEASE_REF }}}}` from vars.ENGINE_REF with its shape checked." - ), - )); - continue; - }; - // The variable's definition: the job's own env first, then the workflow's. - let def = key_value(&job_text, var).or_else(|| key_value(&top_env, var)); - match def.as_deref() { - Some(d) if is_sha40(d) => continue, - Some(d) if d.starts_with("${{") => {} - other => { - bad.push(Finding::new( - "R16", - format!( - "{name}: job `{job}` checks out {ENGINE_REPO} at ${var} = `{}`, which \ - is not a commit: a branch or tag the engine's owner can move. Pin a \ - 40-hex sha, or set {var} from vars.ENGINE_REF.", - other.unwrap_or("(undefined)") - ), - )); - continue; - } - } - let guarded = lines[..i].iter().any(|l| { - l.contains(SHA40_TEST) - && (l.contains(&format!("${var}\"")) - || l.contains(&format!("${{{var}}}")) - || l.contains(&format!("${var} "))) - && l.contains("exit") - }); - if !guarded { - bad.push(Finding::new( - "R16", - format!( - "{name}: job `{job}` checks out {ENGINE_REPO} at the variable ${var} with \ - no step before it that tests ${var} `{SHA40_TEST}` and exits: an unset or \ - malformed variable then checks out the engine's default branch instead \ - of failing." - ), - )); - } - if !verifies { - bad.push(Finding::new( - "R16", - format!( - "{name}: job `{job}` pins the engine through ${var} but never runs \ - `{SHA256_CHECK}` against ${SHA256_VAR}: a variable pin is only a pin \ - when the bytes are checked at run time." - ), - )); - } - } - } - bad -} - /// One finding, carrying the rule id that owns it so the row set is derived from the findings /// rather than restated beside them. #[derive(Debug, Clone)] @@ -1267,12 +779,6 @@ pub fn check(cx: &Ctx) -> Result, String> { } } - // R16. THE ENGINE RUNS FROM A COMMIT, AND ITS BYTES FROM A DIGEST ([`engine_pin_findings`]). - for name in &names { - let text = strip_comments(&read(name).unwrap_or_default()); - bad.extend(engine_pin_findings(name, &text)); - } - // R17. NO JOB HARD-CODES A VENDOR RUNNER LABEL. // // Every job asks the runner gateway for a logical size (small, medium, large, xlarge) and the @@ -1289,38 +795,12 @@ pub fn check(cx: &Ctx) -> Result, String> { format!( "{name}:{line}: job '{job}' runs on the vendor label '{label}'. A workflow \ never names a runner vendor: use `fromJSON(vars.RUNNERS)..label` \ - (size is small, medium, large or xlarge) or the preflight output \ - `needs.preflight.outputs.runner`." + (size is small, medium, large or xlarge)." ), )); } } - // R18. A STEP THAT DOWNLOADS THE RELEASE ARTIFACT FROM ITS OWN RUN RUNS ONLY WHERE IT IS BUILT. - // - // The shards of a rung that runs the oracle or conformance (`needs_release_artifact=true`: the - // predev->dev pull request and up) download `slsa-artifact-` from their own run, after - // `ci turnstile await-job` reads `slsa-build` green, and a SKIPPED job reads green there. With - // slsa-build gated to merge groups, every predev->dev run waited on nothing, failed that - // download on all four shards, and the fold called it INFRA: #740, every run. The rule holds - // the builder's `if:` to admit every condition a same-run download of its artifact runs under. - for name in &names { - let text = strip_comments(&read(name).unwrap_or_default()); - bad.extend(release_artifact_findings(name, &text)); - } - - // R19. NO JOB THAT RUNS CANDIDATE CODE HOLDS THE R2 CACHE WRITE KEYS ([`r2_write_key_findings`]). - // - // The shards and the one-runner hop passed `R2_KEY_ID` / `R2_KEY_SECRET` (the write keys) to - // `ci hop`, whose judge runs the candidate's build scripts, tests and xtask: any of them could - // read the keys and write the bucket every later hop reads its base reports and step PASSes - // from. busbar-release 069f032 refuses (INFRA) a hop that holds them; this rule keeps the keys - // from coming back to any job that runs candidate code, where the hop's refusal is not there. - for name in &names { - let text = strip_comments(&read(name).unwrap_or_default()); - bad.extend(r2_write_key_findings(name, &text)); - } - Ok(bad) } @@ -1335,10 +815,7 @@ fn rule_title(rule: &str) -> &'static str { "R11" => "no workflow pushes a commit to a release branch", "R14" => "every third-party action runs from a commit sha", "R15" => "every attestation verify names the workflow that signed", - "R16" => "the engine runs from a commit sha, its bytes from a sha256", "R17" => "no job hard-codes a vendor runner label", - "R18" => "a same-run download of the release artifact runs only where it is built", - "R19" => "no job that runs candidate code holds the R2 cache write keys", _ => "workflow rule", } } @@ -1526,7 +1003,7 @@ fn mutations() -> Vec { file: "zz-planted-verify.yml", rule: "R15", apply: |_| { - "jobs:\n v:\n steps:\n - run: |\n gh attestation verify ./x --repo GetBusbar/busbar --signer-workflow GetBusbar/busbar/.github/workflows/promote.yml\n" + "jobs:\n v:\n steps:\n - run: |\n gh attestation verify ./x --repo GetBusbar/busbar --signer-workflow GetBusbar/busbar/.github/workflows/plugin-ci.yml\n" .to_string() }, creates: true, @@ -1579,24 +1056,31 @@ fn mutations() -> Vec { creates: false, }, Mutation { - // THE SAME TWO REGRESSIONS IN THE FLOW SPELLING the pipeline workflow itself is - // written in: a step as one `{ uses: ..., with: ... }` mapping. + // THE SAME TWO REGRESSIONS IN THE FLOW SPELLING: a step written as one + // `{ uses: ..., with: ... }` mapping. No remaining workflow is written that way (the + // flow-spelled promote.yml is deleted), so the regression is a workflow that is. label: "R14 a flow-style action reverts from a sha to a force-movable tag", - file: "promote.yml", + file: "zz-planted-flow-pin.yml", rule: "R14", - apply: |t| retag_first_pin(t, false), - creates: false, + apply: |_| { + "jobs:\n a:\n steps:\n - { uses: actions/checkout@v7, with: { fetch-depth: 0 } }\n" + .to_string() + }, + creates: true, }, Mutation { label: "R14 a flow-style pin loses the trailing tag comment Dependabot reads", - file: "promote.yml", + file: "zz-planted-flow-pin.yml", rule: "R14", - apply: |t| retag_first_pin(t, true), - creates: false, + apply: |_| { + "jobs:\n a:\n steps:\n - { uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1, with: { fetch-depth: 0 } }\n" + .to_string() + }, + creates: true, }, Mutation { label: "R1 a v* tag trigger comes back", - file: "promote.yml", + file: "plugin-ci.yml", rule: "R1", apply: |t| replant_on(t, "on:\n push:\n tags:\n - \"v*\"\n"), creates: false, @@ -1605,75 +1089,17 @@ fn mutations() -> Vec { // THE SAME RULE, IN THE OTHER YAML SPELLING. The block form above was caught; the flow // form is ordinary YAML the runner treats identically. label: "R1 a v* tag trigger comes back as a FLOW sequence", - file: "promote.yml", + file: "plugin-ci.yml", rule: "R1", apply: |t| replant_on(t, "on:\n push:\n tags: [\"v*\"]\n"), creates: false, }, - Mutation { - // THE REGRESSION AS IT WOULD ACTUALLY ARRIVE: "just follow dev" -- the engine checked - // out at its branch, so every hop runs whatever dev holds at that minute. - label: "R16 the engine is checked out at a branch", - file: "promote.yml", - rule: "R16", - apply: |t| { - replace_once( - t, - "repository: GetBusbar/busbar-release, ref: \"${{ env.RELEASE_REF }}\"", - "repository: GetBusbar/busbar-release, ref: dev", - ) - }, - creates: false, - }, - Mutation { - // The same through the variable: the pin becomes a release tag. - label: "R16 the engine pin becomes a movable tag", - file: "promote.yml", - rule: "R16", - apply: |t| { - replace_once( - t, - "RELEASE_REF: \"${{ vars.ENGINE_REF }}\"", - "RELEASE_REF: v1.6.0", - ) - }, - creates: false, - }, - Mutation { - // The variable form loses its shape check: an unset variable is an empty `ref:`, and - // an empty `ref:` is the default branch, a silent fallback instead of a FAIL. - label: "R16 the variable pin's 40-hex shape check is dropped", - file: "promote.yml", - rule: "R16", - apply: |t| drop_first_line_with(t, SHA40_TEST), - creates: false, - }, - Mutation { - label: "R16 the engine binary runs without its sha256 check", - file: "promote.yml", - rule: "R16", - apply: |t| drop_first_line_with(t, SHA256_CHECK), - creates: false, - }, - Mutation { - label: "R16 the pinned oracle runs without its sha256 check", - file: "promote.yml", - rule: "R16", - apply: |t| { - replace_once( - t, - "echo \"$ORACLE_SHA256 $RUNNER_TEMP/busbar-oracle\" | sha256sum -c -\n", - "", - ) - }, - creates: false, - }, Mutation { // The refspec form a promote script documents, which the `HEAD:` test and the // no-refspec test both walked past. THE ANCHOR IS `steps:`, NOT A `uses:` LINE: a // structural feature of every job cannot be renamed by a pin bump or a formatting pass. label: "R11 a workflow pushes a refspec straight to main", - file: "promote.yml", + file: "plugin-ci.yml", rule: "R11", apply: |t| { replace_once( @@ -1684,109 +1110,9 @@ fn mutations() -> Vec { }, creates: false, }, - Mutation { - // THE REGRESSION AS IT ARRIVED (#740): the release build gated to merge groups while - // the predev->dev pull request's shards still download its artifact from their run. - label: "R18 slsa-build goes back to merge-group-only under a pull request's download", - file: "promote.yml", - rule: "R18", - apply: |t| { - // The shards no longer download the artifact (nothing they run reads it), so the - // pull request's same-run download is planted back into hop-shard, gated on the - // rung as it was, with slsa-build narrowed to merge groups. - let t = plant_step_in_job( - t, - "hop-shard", - " - if: needs.preflight.outputs.needs_release_artifact == 'true'\n uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1\n with: { name: \"slsa-artifact-${{ env.SHA }}\", path: slsa }\n", - ); - reif_job( - &t, - "slsa-build", - "github.event_name == 'merge_group' && needs.preflight.outputs.run == '1'", - ) - }, - creates: false, - }, - Mutation { - // THE REGRESSION AS IT STOOD BEFORE busbar-release 069f032: a hop shard's `ci hop` holds - // the R2 write key again, where every candidate process it starts can read it. - label: "R19 a hop shard's `ci hop` holds the R2 cache write key", - file: "promote.yml", - rule: "R19", - apply: |t| { - replace_once( - t, - r#"R2_RO_KEY_ID: "${{ secrets.R2_CACHE_RO_ACCESS_KEY_ID }}""#, - r#"R2_KEY_ID: "${{ secrets.R2_CACHE_ACCESS_KEY_ID }}""#, - ) - }, - creates: false, - }, ] } -/// Insert `step` (a whole ` - ...\n` step) as the first step of job `job`. A renamed job or one -/// without `steps:` edits nothing, which the selftest reports as unproven. -fn plant_step_in_job(t: &str, job: &str, step: &str) -> String { - let head = format!(" {job}:"); - let mut in_job = false; - let mut done = false; - let mut out = String::with_capacity(t.len() + step.len()); - for line in t.split_inclusive('\n') { - let bare = line.trim_end_matches('\n'); - if opens_key_at(bare, 2) { - in_job = bare.trim_end() == head; - } - out.push_str(line); - if !done && in_job && bare == " steps:" { - out.push_str(step); - done = true; - } - } - out -} - -/// Replace job `job`'s own `if:` line with `if: `. The anchor is the job id and its -/// four-space `if:` key, not today's condition, so rewording the condition cannot unprove R18; a -/// renamed or unconditional job edits nothing, which the selftest reports as unproven. -fn reif_job(t: &str, job: &str, cond: &str) -> String { - let head = format!(" {job}:"); - let mut in_job = false; - let mut done = false; - let mut out = String::with_capacity(t.len() + cond.len()); - for line in t.split_inclusive('\n') { - let bare = line.trim_end_matches('\n'); - if opens_key_at(bare, 2) { - in_job = bare.trim_end() == head; - } - if !done && in_job && opens_key_at(bare, 4) && bare.trim_start().starts_with("if:") { - out.push_str(" if: "); - out.push_str(cond); - out.push('\n'); - done = true; - } else { - out.push_str(line); - } - } - out -} - -/// `t` without the first line containing `needle` (unchanged when none does: the case then -/// reports its anchor moved). -fn drop_first_line_with(t: &str, needle: &str) -> String { - let mut done = false; - t.split_inclusive('\n') - .filter(|l| { - if !done && l.contains(needle) { - done = true; - false - } else { - true - } - }) - .collect() -} - /// The R14 mutations both target the FIRST fully-pinned third-party action in the file, found by /// shape rather than by a literal sha. A literal would rot the day that action is bumped, and a /// mutation that edits nothing proves nothing. diff --git a/xtask/src/scan.rs b/xtask/src/scan.rs index 52e5569789..04eebb6684 100644 --- a/xtask/src/scan.rs +++ b/xtask/src/scan.rs @@ -12,7 +12,7 @@ /// dropped, one production-code line per output entry, 1-based line numbers. pub fn production_lines(src: &str) -> Vec<(usize, String)> { let mut out = Vec::new(); - let mut in_block_comment = false; + let mut in_block_comment = LexState::default(); let mut pending_test_attr = false; let mut test_mod_depth: Option = None; let mut depth: i32 = 0; @@ -346,8 +346,39 @@ pub fn blank_code(line: &str, st: &mut LexState) -> String { /// lets [`code_of`] strip a trailer off a line that also holds a string. pub fn blank_code_marking(line: &str, st: &mut LexState) -> (String, Option) { let chars: Vec = line.chars().collect(); - let mut comment_at: Option = None; let mut out = String::with_capacity(line.len()); + let mut at = 0; + let comment_at = lex_line(&chars, st, |class, n| { + for c in &chars[at..at + n] { + out.push(if class == Class::Code { *c } else { ' ' }); + } + at += n; + }); + (out, comment_at) +} + +/// What one char of a line is, to THE ONE LEXER. [`blank_code_marking`] keeps only [`Class::Code`]; +/// [`split_comment_line`] keeps code AND literal text in its code half and [`Class::Comment`] in +/// its comment half. One classification, two readers, so the comment stripper and the delimiter +/// counter cannot disagree about where a string or a comment ends. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Class { + /// Code, including every literal's quote delimiters and a byte char's `b`. + Code, + /// The inside of a literal: its body, a raw or byte prefix, a raw literal's closing `#`s. + Literal, + /// The text of a comment. + Comment, + /// A comment's own `//`, `/*` or `*/`. + CommentMark, +} + +/// Classify every char of a line, in order, carrying `st` from the previous line: `put(class, n)` +/// says the next `n` chars are `class`, and the calls cover the line exactly once. Answers where +/// this line's `//` comment began, if it has one. A sink rather than a returned list, so the hot +/// reader ([`blank_code`], on every line of every scan) still allocates only its output. +fn lex_line(chars: &[char], st: &mut LexState, mut put: impl FnMut(Class, usize)) -> Option { + let mut comment_at: Option = None; let mut i = 0; while i < chars.len() { @@ -356,14 +387,14 @@ pub fn blank_code_marking(line: &str, st: &mut LexState) -> (String, Option 0 { if c == '/' && chars.get(i + 1) == Some(&'*') { st.block += 1; - out.push_str(" "); + put(Class::CommentMark, 2); i += 2; } else if c == '*' && chars.get(i + 1) == Some(&'/') { st.block -= 1; - out.push_str(" "); + put(Class::CommentMark, 2); i += 2; } else { - out.push(' '); + put(Class::Comment, 1); i += 1; } continue; @@ -372,29 +403,26 @@ pub fn blank_code_marking(line: &str, st: &mut LexState) -> (String, Option { if c == '\\' { - out.push(' '); - if i + 1 < chars.len() { - out.push(' '); - } + put(Class::Literal, if i + 1 < chars.len() { 2 } else { 1 }); i += 2; } else if c == '"' { st.open = None; - out.push('"'); + put(Class::Code, 1); i += 1; } else { - out.push(' '); + put(Class::Literal, 1); i += 1; } continue; } Some(OpenLit::Raw(hashes)) => { - if c == '"' && closes_raw(&chars, i + 1, hashes) { + if c == '"' && closes_raw(chars, i + 1, hashes) { st.open = None; - out.push('"'); - out.extend(std::iter::repeat_n(' ', hashes)); + put(Class::Code, 1); + put(Class::Literal, hashes); i += 1 + hashes; } else { - out.push(' '); + put(Class::Literal, 1); i += 1; } continue; @@ -404,53 +432,53 @@ pub fn blank_code_marking(line: &str, st: &mut LexState) -> (String, Option Option { (chars.get(i + 1).is_some() && chars.get(i + 2) == Some(&'\'')).then_some(i + 2) } -/// One line split into **(code, comment)** — the same single pass as [`strip_comment_line`], which -/// now delegates here, except the comment text is HANDED BACK rather than dropped on the floor. +/// What a line-by-line comment stripper carries from one line to the next. /// -/// There is one lexer so the two halves cannot disagree: anything absent from `code` is present in -/// `comment` and vice versa. String literals stay verbatim in the CODE half, so a `//` inside a -/// `"…"` is not a comment — which is precisely the distinction -/// [`crate::gates::no_deferral`]'s Class-B comment tags stand on. A `TODO(migrate):` inside a -/// `format!("# TODO(migrate): {t}")` is **data the program emits**, not a label its author attached -/// to this code, and only the second of those is a deferral marker. -pub fn split_comment_line(line: &str, in_block: &mut bool) -> (String, String) { - let mut code = String::new(); - let mut comment = String::new(); - let bytes: Vec = line.chars().collect(); - let mut i = 0; - let mut in_str = false; - while i < bytes.len() { - if *in_block { - if bytes[i] == '*' && bytes.get(i + 1) == Some(&'/') { - *in_block = false; - i += 2; - } else { - comment.push(bytes[i]); - i += 1; - } - continue; - } - if in_str { - code.push(bytes[i]); - if bytes[i] == '\\' { - if let Some(c) = bytes.get(i + 1) { - code.push(*c); - } - i += 2; - continue; - } - if bytes[i] == '"' { - in_str = false; - } - i += 1; - continue; - } - if bytes[i] == '/' && bytes.get(i + 1) == Some(&'*') { - *in_block = true; - i += 2; - continue; - } - if bytes[i] == '/' && bytes.get(i + 1) == Some(&'/') { - comment.extend(bytes[i + 2..].iter()); - break; - } - if bytes[i] == '"' { - in_str = true; - code.push(bytes[i]); - i += 1; - continue; +/// [`LexState`] is the whole answer: an open block comment AND an open literal. Carrying only the +/// block flag is the defect this trait exists to end — the continuation line of a multi-line +/// string (`"{root}/*"` after a `\` line end) was read from a clean state, its `/*` opened a block +/// comment, and no `*/` ever came: the rest of `crates/busbar-kernel/src/plugin_routes.rs` (292 of +/// its 564 lines) read as comment to every gate built on this scanner. +/// +/// `bool` remains a carrier only so a caller not yet moved to [`LexState`] still compiles. It holds +/// the block comment and NOTHING ELSE, so it still loses an open literal at a line end and still +/// inherits the defect; every caller that can say `LexState::default()` should. +pub trait CommentCarry { + fn load(&self) -> LexState; + fn store(&mut self, st: LexState); +} + +impl CommentCarry for LexState { + fn load(&self) -> LexState { + *self + } + fn store(&mut self, st: LexState) { + *self = st; + } +} + +impl CommentCarry for bool { + fn load(&self) -> LexState { + LexState { + block: u32::from(*self), + open: None, } - code.push(bytes[i]); - i += 1; } + fn store(&mut self, st: LexState) { + *self = st.block > 0; + } +} + +/// One line split into **(code, comment)**, read by THE ONE LEXER (`lex_line`) — the same +/// classification [`blank_code`] reads — with the comment text HANDED BACK rather than dropped. +/// +/// Anything absent from `code` is present in `comment` or is a comment's own `//`, `/*` or `*/`. +/// String literals stay verbatim in the CODE half, so a `//` inside a `"…"` is not a comment — which +/// is precisely the distinction [`crate::gates::no_deferral`]'s Class-B comment tags stand on. A +/// `TODO(migrate):` inside a `format!("# TODO(migrate): {t}")` is **data the program emits**, not a +/// label its author attached to this code, and only the second of those is a deferral marker. +/// +/// `carry` holds what the previous line left open; see [`CommentCarry`] for why it must be a +/// [`LexState`]. Raw strings (`r#"…"#`), byte strings, escapes, char literals (`'"'`) and lifetimes +/// (`'a`) are told apart exactly as [`blank_code`] tells them apart, and block comments nest. +pub fn split_comment_line(line: &str, carry: &mut impl CommentCarry) -> (String, String) { + let chars: Vec = line.chars().collect(); + let mut code = String::new(); + let mut comment = String::new(); + let mut at = 0; + let mut st = carry.load(); + lex_line(&chars, &mut st, |class, n| { + let run = &chars[at..at + n]; + match class { + Class::Code | Class::Literal => code.extend(run), + Class::Comment => comment.extend(run), + Class::CommentMark => {} + } + at += n; + }); + carry.store(st); (code, comment) } -/// One line with `//` and `/* */` comments removed. `in_block` carries block-comment state across -/// lines. String literals are preserved verbatim, so a `//` inside a `"…"` is not a comment. -pub fn strip_comment_line(line: &str, in_block: &mut bool) -> String { - split_comment_line(line, in_block).0 +/// One line with `//` and `/* */` comments removed. `carry` carries the comment and literal state +/// across lines ([`CommentCarry`]). String literals are preserved verbatim, so a `//` inside a +/// `"…"` is not a comment. +pub fn strip_comment_line(line: &str, carry: &mut impl CommentCarry) -> String { + split_comment_line(line, carry).0 } /// EVERY FILE WHOSE MODULE IS DECLARED UNDER A TEST-ONLY `cfg`, among `files` (repo-relative path, diff --git a/xtask/tests/ci_test_fixtures.rs b/xtask/tests/ci_test_fixtures.rs new file mode 100644 index 0000000000..a70ad85d9f --- /dev/null +++ b/xtask/tests/ci_test_fixtures.rs @@ -0,0 +1,130 @@ +//! A test that loads an example cdylib names the command that builds it, and fails (never skips) +//! when the library is absent. ModernCI's `scope: affected` narrows a run to the crates a change +//! touches, so a run of `busbar` alone used to build no other package's examples: the loader's +//! neutral frame door was missing and 22 composition-root tests failed on a change that never +//! touched them. Every CI step that runs the suites therefore builds each named fixture package BY +//! NAME before its first suite, and this file holds the CI files to that. + +use std::path::{Path, PathBuf}; + +fn repo_root() -> &'static Path { + Path::new(env!("CARGO_MANIFEST_DIR")).parent().unwrap() +} + +fn rust_files(dir: &Path, out: &mut Vec) { + let Ok(entries) = std::fs::read_dir(dir) else { + return; + }; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + if path.file_name().is_some_and(|n| n == "target") { + continue; + } + rust_files(&path, out); + } else if path.extension().is_some_and(|e| e == "rs") { + out.push(path); + } + } +} + +/// The packages whose examples some test under `crates/` tells its reader to build, read from the +/// command the test prints (`cargo build -p --examples`). +fn fixture_packages(root: &Path) -> Vec { + const HEAD: &str = "cargo build -p "; + const TAIL: &str = " --examples"; + let mut files = Vec::new(); + rust_files(&root.join("crates"), &mut files); + let mut packages: Vec = Vec::new(); + for file in files { + let text = std::fs::read_to_string(&file).unwrap_or_default(); + for line in text.lines() { + let mut rest = line; + while let Some(at) = rest.find(HEAD) { + let after = &rest[at + HEAD.len()..]; + let Some(end) = after.find(TAIL) else { break }; + let package = &after[..end]; + if !package.is_empty() && !package.contains(' ') { + packages.push(package.to_string()); + } + rest = &after[end..]; + } + } + } + packages.sort(); + packages.dedup(); + packages +} + +/// Every `run` block in `.ci/*.yaml` that runs the suites, by `:`. +fn suite_steps(root: &Path) -> Vec<(String, String)> { + let mut steps = Vec::new(); + let mut files: Vec = std::fs::read_dir(root.join(".ci")) + .unwrap() + .flatten() + .map(|e| e.path()) + .filter(|p| p.extension().is_some_and(|e| e == "yaml")) + .collect(); + files.sort(); + for file in files { + let text = std::fs::read_to_string(&file).unwrap(); + let doc: serde_yaml::Value = serde_yaml::from_str(&text) + .unwrap_or_else(|e| panic!("{} does not parse: {e}", file.display())); + let Some(checks) = doc.get("checks").and_then(|c| c.as_mapping()) else { + continue; + }; + for (name, check) in checks { + let Some(run) = check.get("run").and_then(|r| r.as_str()) else { + continue; + }; + if run.contains("cargo nextest run") { + let label = format!( + "{}:{}", + file.file_name().unwrap().to_string_lossy(), + name.as_str().unwrap_or("?") + ); + steps.push((label, run.to_string())); + } + } + } + steps +} + +#[test] +fn an_affected_scope_run_of_busbar_alone_builds_the_examples_its_tests_load() { + let root = repo_root(); + let packages = fixture_packages(root); + assert!( + packages.iter().any(|p| p == "busbar-plugin-loader") + && packages.iter().any(|p| p == "busbar"), + "the scan found no test naming the loader's or busbar's example build, so it proved \ + nothing: {packages:?}" + ); + let steps = suite_steps(root); + assert!( + steps.iter().any(|(label, _)| label == "default.yaml:test"), + "no suite step was found in .ci/ (expected default.yaml:test): {:?}", + steps.iter().map(|(l, _)| l).collect::>() + ); + let mut missing = Vec::new(); + for (label, run) in &steps { + let before_suites: Vec<&str> = run + .lines() + .map(str::trim) + .take_while(|l| !l.starts_with("cargo nextest run")) + .collect(); + for package in &packages { + let wanted = format!("cargo build --locked -p {package} --examples"); + if !before_suites.contains(&wanted.as_str()) { + missing.push(format!("{label}: `{wanted}`")); + } + } + } + assert!( + missing.is_empty(), + "a suite step can run without the example cdylibs its tests load; a run scoped to one \ + crate builds no other package's examples, so each must be built by name before the \ + first suite:\n{}", + missing.join("\n") + ); +} diff --git a/xtask/tests/conformance_rigs.rs b/xtask/tests/conformance_rigs.rs index 25cff8d349..0fcae2cc76 100644 --- a/xtask/tests/conformance_rigs.rs +++ b/xtask/tests/conformance_rigs.rs @@ -221,6 +221,152 @@ fn at_the_node_width_the_jev_ledger_is_the_callers_bucket() { assert!(judge_jev_ledger(&bucketed(&[("vk_caller", "", "", 1, "41")]), "vk_caller").is_err()); } +/// THE MODEL-RESOLUTION BOOT (THE DESIGN §2, the decisions bullet): two models, the second with an +/// `upstream_model`, each priced. +#[test] +fn the_models_subject_configures_two_models_one_with_an_upstream_model() { + use xtask::conformance_record::{jev_models_config, UPSTREAM_MODEL, UPSTREAM_NAME}; + let text = jev_models_config(41000, 41001, std::path::Path::new("/tmp/signing.key")); + let doc: serde_yaml::Value = serde_yaml::from_str(&text).expect("the config is YAML"); + let models = &doc["decisions"]["models"]; + assert_eq!(models["jev-1"]["provider"].as_str(), Some("typesafe")); + assert!(models["jev-1"]["upstream_model"].is_null()); + assert_eq!( + models[UPSTREAM_MODEL]["upstream_model"].as_str(), + Some(UPSTREAM_NAME) + ); + for m in ["jev-1", UPSTREAM_MODEL] { + assert_eq!( + doc["decisions"]["rate_card"][m]["units"]["decision"].as_u64(), + Some(1), + "{m}" + ); + } + // THE DESIGN §7: the card prices `decision`, `input_tokens` and `output_tokens`, all three + // required (0 = free), on both subjects. + let main = xtask::conformance_record::jev_subject_config( + 41000, + 41001, + std::path::Path::new("/tmp/signing.key"), + ); + let main: serde_yaml::Value = serde_yaml::from_str(&main).expect("the config is YAML"); + for (doc, m) in [(&doc, "jev-1"), (&doc, UPSTREAM_MODEL), (&main, "jev-1")] { + for class in ["input_tokens", "output_tokens"] { + assert_eq!( + doc["decisions"]["rate_card"][m]["units"][class].as_u64(), + Some(0), + "{m} {class}" + ); + } + } + assert_eq!(doc["store"]["module"].as_str(), Some("memory")); +} + +fn call(status: u16, body: &[u8], hops: usize) -> xtask::conformance_record::Call { + xtask::conformance_record::Call { + status, + body: body.to_vec(), + hops, + } +} + +/// `models.unnamed-400` / `models.unknown-404`: the status in jev's shape, before any hop. +#[test] +fn an_unrouted_request_is_refused_in_jev_shape_before_any_hop() { + use xtask::conformance_record::judge_unrouted; + let bad = br#"{"error":{"code":"invalid_request","message":"name one"}}"#; + assert!(judge_unrouted(&call(400, bad, 0), 400, "invalid_request").is_ok()); + // RED ARMS: another status, a hop, another shape. + assert!(judge_unrouted(&call(404, bad, 0), 400, "invalid_request").is_err()); + assert!(judge_unrouted(&call(400, bad, 1), 400, "invalid_request").is_err()); + assert!(judge_unrouted(&call(400, b"{}", 0), 400, "invalid_request").is_err()); +} + +/// `models.upstream-model`: served, and the far end saw the top-level `model` value alone spliced. +#[test] +fn the_upstream_model_is_spliced_into_what_the_far_end_receives() { + use xtask::conformance_record::{ + judge_upstream_model, models_request, JEV_SUCCESS, UPSTREAM_MODEL, UPSTREAM_NAME, + }; + let sent = models_request(Some(UPSTREAM_MODEL)); + let spliced = String::from_utf8(sent.clone()) + .unwrap() + .replace(UPSTREAM_MODEL, UPSTREAM_NAME); + let ok = call(200, JEV_SUCCESS, 1); + assert!(judge_upstream_model(&ok, &sent, spliced.as_bytes()).is_ok()); + // RED ARMS: the caller's own name relayed, the far end not reached, another answer. + assert!(judge_upstream_model(&ok, &sent, &sent).is_err()); + assert!(judge_upstream_model(&call(200, JEV_SUCCESS, 0), &sent, spliced.as_bytes()).is_err()); + assert!(judge_upstream_model(&call(400, b"{}", 1), &sent, spliced.as_bytes()).is_err()); + // The unnamed request names no model. + assert!(!String::from_utf8(models_request(None)) + .unwrap() + .contains("\"model\"")); +} + +/// THE `nanojev` BOOT (THE DESIGN §2, the decisions bullet): a NanoJev model on a `nanojev` +/// provider beside the jev one, each priced. +#[test] +fn the_nanojev_subject_configures_a_nanojev_provider_and_model() { + use xtask::conformance_record::{ + jev_nanojev_config, nanojev_providers, NANOJEV_MODEL, NANOJEV_PROVIDER, + }; + let text = jev_nanojev_config(41000, 41001, std::path::Path::new("/tmp/signing.key")); + let doc: serde_yaml::Value = serde_yaml::from_str(&text).expect("the config is YAML"); + assert_eq!( + doc["decisions"]["models"][NANOJEV_MODEL]["provider"].as_str(), + Some(NANOJEV_PROVIDER) + ); + assert_eq!( + doc["decisions"]["models"]["jev-1"]["provider"].as_str(), + Some("typesafe") + ); + assert!(!doc["providers"][NANOJEV_PROVIDER].is_null()); + for m in ["jev-1", NANOJEV_MODEL] { + assert_eq!( + doc["decisions"]["rate_card"][m]["units"]["decision"].as_u64(), + Some(1), + "{m}" + ); + } + let providers: serde_yaml::Value = + serde_yaml::from_str(&nanojev_providers(9)).expect("the providers are YAML"); + assert_eq!( + providers[NANOJEV_PROVIDER]["protocol"].as_str(), + Some("nanojev") + ); + assert_eq!(providers["typesafe"]["protocol"].as_str(), Some("jev")); +} + +/// `nanojev.served` and `nanojev.no-count-refused`. +#[test] +fn a_nanojev_unit_is_served_at_evaluate_and_a_missing_count_refused() { + use xtask::conformance_record::{ + judge_nanojev_no_count, judge_nanojev_served, NANOJEV_NO_COUNT, NANOJEV_REQUEST, + NANOJEV_SUCCESS, PATH_EVALUATE, + }; + let ok = call(200, NANOJEV_SUCCESS, 1); + assert!(judge_nanojev_served(&ok, PATH_EVALUATE, NANOJEV_REQUEST).is_ok()); + // RED ARMS: another path, other bytes, another answer, no dial. + assert!(judge_nanojev_served(&ok, "/v1/systemone", NANOJEV_REQUEST).is_err()); + assert!(judge_nanojev_served(&ok, PATH_EVALUATE, b"{}").is_err()); + assert!(judge_nanojev_served(&call(200, b"{}", 1), PATH_EVALUATE, NANOJEV_REQUEST).is_err()); + assert!(judge_nanojev_served( + &call(200, NANOJEV_SUCCESS, 0), + PATH_EVALUATE, + NANOJEV_REQUEST + ) + .is_err()); + + let refused = br#"{"error":{"code":"internal","message":"plane fault"}}"#; + assert!(judge_nanojev_no_count(&call(502, refused, 1)).is_ok()); + // RED ARMS: the far end's bytes relayed, a success, an extra dial, another shape. + assert!(judge_nanojev_no_count(&call(200, NANOJEV_NO_COUNT, 1)).is_err()); + assert!(judge_nanojev_no_count(&call(502, NANOJEV_NO_COUNT, 1)).is_err()); + assert!(judge_nanojev_no_count(&call(502, refused, 2)).is_err()); + assert!(judge_nanojev_no_count(&call(502, b"oops", 1)).is_err()); +} + // ── a2a ─────────────────────────────────────────────────────────────────────────────────────── fn report(rows: &[(&str, &str)]) -> String { diff --git a/xtask/tests/infra.rs b/xtask/tests/infra.rs index 61f9dee334..048d0a649e 100644 --- a/xtask/tests/infra.rs +++ b/xtask/tests/infra.rs @@ -490,6 +490,66 @@ fn strip_comment_line_keeps_string_literals_intact() { ); } +#[test] +fn a_comment_opener_inside_a_continued_string_is_string() { + // `crates/busbar-kernel/src/plugin_routes.rs`'s shape: a string continued with `\`, whose next + // line holds `/*`. The stripper reset its in-string flag per line, read that `/*` as a block + // comment that never closed, and every later line of the file came back empty (audit xtask + // A2 HIGH 1, H1 HIGH 2). + let src = "fn confine(root: &str) -> String {\n format!(\"confined to \\\n \ + {root}/*\")\n}\nfn after() { todo!() }\n"; + let mut st = scan::LexState::default(); + let code: Vec = src + .lines() + .map(|l| scan::strip_comment_line(l, &mut st)) + .collect(); + assert_eq!( + code[2], " {root}/*\")", + "the continuation is string, not comment" + ); + assert_eq!( + code[4], "fn after() { todo!() }", + "the code after it is still code" + ); + assert_eq!( + st, + scan::LexState::default(), + "nothing is left open at end of file" + ); + let prod = scan::production_lines(src); + assert!( + prod.contains(&(5, "fn after() { todo!() }".to_string())), + "production_lines must hand back the line after the string: {prod:?}" + ); +} + +#[test] +fn the_comment_splitter_reads_raw_strings_chars_lifetimes_and_nesting() { + let mut st = scan::LexState::default(); + let mut split = |l: &str| scan::split_comment_line(l, &mut st); + // A raw string: `"` does not close it, `\` is not an escape, `/*` and `//` inside are string. + assert_eq!( + split("let r = r#\"a \\\" /* \"# ; // c"), + ("let r = r#\"a \\\" /* \"# ; ".to_string(), " c".to_string()) + ); + // ...and it spans lines. + assert_eq!(split("let r = r#\"one \"").0, "let r = r#\"one \""); + assert_eq!(split("/* still raw \"# ; x").0, "/* still raw \"# ; x"); + // A char literal holding `"` opens no string; a lifetime is not a char literal. + assert_eq!( + split("let q = '\"'; f::<'a>(); // c").0, + "let q = '\"'; f::<'a>(); " + ); + // A byte string with an escaped quote, then a comment. + assert_eq!( + split("let b = b\"\\\"/*\"; // c").0, + "let b = b\"\\\"/*\"; " + ); + // Block comments nest: the first `*/` closes only the inner one. + assert_eq!(split("a /* x /* y */ z").0, "a "); + assert_eq!(split("w */ b"), (" b".to_string(), "w ".to_string())); +} + // ── planes ────────────────────────────────────────────────────────────────────────────────────── #[test] @@ -722,35 +782,35 @@ jobs: } #[test] -fn the_real_pipeline_workflow_parses_and_its_gate_call_sites_are_discoverable() { - let text = std::fs::read_to_string(repo_root().join(".github/workflows/promote.yml")).unwrap(); - let wf = yaml_lite::parse_workflow(&text).expect("the real promote.yml parses"); +fn every_gate_the_modernci_branch_files_call_is_registered() { + // THE PIPELINE IS `.ci/*.yaml` (OWNER 2026-10-12, "ModernCI only"): ModernCI runs each branch + // file's checks on every move, and promote.yml, the busbar-release hop, is deleted. EVERY GATE A + // BRANCH FILE CALLS BY NAME IS ONE THE REGISTRY ANSWERS TO: a `cargo xtask gate ` is a + // check that exits 2 on every move, and the registry is what can say so here rather than in a + // red move. `.ci/default.yaml` calls `abi-header` by name, so the scan is never empty. + let dir = repo_root().join(".ci"); + let mut files: Vec<_> = std::fs::read_dir(&dir) + .unwrap() + .filter_map(|e| e.ok().map(|e| e.path())) + .filter(|p| p.extension().is_some_and(|x| x == "yaml" || x == "yml")) + .collect(); + files.sort(); + let mut called_any = false; + for path in &files { + let text = std::fs::read_to_string(path).unwrap(); + for name in yaml_lite::xtask_gate_invocations(&text) { + called_any = true; + assert!( + gates::find(&name).is_some(), + "{} calls `cargo xtask gate {name}`, which no registration answers to", + path.display() + ); + } + } assert!( - wf.job_names().len() >= 2, - "promote.yml carries the preflight and hop jobs: {:?}", - wf.job_names() + called_any, + "no `.ci/*.yaml` calls a gate by name: the scan read nothing ({files:?})" ); - assert!( - wf.job_names() - .iter() - .any(|j| wf.job(j).unwrap().steps.iter().any(|s| s.run.is_some())), - "every job's `run:` steps must be visible to the reader" - ); - - // EVERY GATE THE PIPELINE CALLS BY NAME IS ONE THE REGISTRY ANSWERS TO: a `cargo xtask gate - // ` is a step that exits 2 on every hop, and the registry is what can say so here rather - // than in a red run. - // promote.yml is a thin bootstrap: it runs busbar-release's `busbar-release ci hop` at - // RELEASE_REF, and the gate calls (the C header's `abi-header`, the registry's `--list`) live in - // that engine, not in this file. So the file names no gate today; the loop below keeps holding - // anything it ever does name to the registry. - let called = yaml_lite::xtask_gate_invocations(&text); - for name in &called { - assert!( - gates::find(name).is_some(), - "promote.yml calls `cargo xtask gate {name}`, which no registration answers to" - ); - } assert_eq!( yaml_lite::xtask_gate_invocations(" run: |\n cargo xtask gate plane-purity --selftest\n cargo xtask gate plane-purity\n"), vec!["plane-purity".to_string()]